Compare commits

...
Author SHA1 Message Date
arkon 0f57342b10 chore: version packages 2026-03-29 05:10:16 +02:00
arkonandClaude Opus 4.6 e1f0ac993a fix: default new sessions to opus[1m] (1M context) instead of opus (200k)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 05:09:24 +02:00
arkon a84ef52992 chore: version packages 2026-03-28 16:49:55 +01:00
arkonandClaude Opus 4.6 692c894760 fix: correct process tree detection and prevent timer starvation
1. Rewrote getActiveChildProcesses() to use a single `ps --ppid` call
   instead of two-level pgrep. The pane PID is typically claude itself
   (bash exec'd into it), not a bash wrapper — so direct children of
   pane_pid ARE the tool processes.

2. Added timer restart in tryStartAiCheck() when skipping due to child
   processes. Without this, the pre-filter and no-output timers (both
   one-shot) would never fire again, permanently stalling idle detection
   for sessions with silent long-running processes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 05:14:39 +01:00
arkonandClaude Opus 4.6 ad0acb6d58 feat: detect active child processes to prevent false idle during running tools
When Claude Code spawns bash tools (test suites, builds, servers), the
respawn controller could falsely detect idle if terminal output paused.
Now checks the process tree for active children of the Claude process
before triggering AI idle checks or confirming idle state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 04:34:53 +01:00
arkonandClaude Opus 4.6 d866c8f30e chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:43:59 +01:00
arkonandClaude Opus 4.6 28537de39d refactor: pass 3 — extract helpers, split long functions, deduplicate patterns
Backend:
- subagent-watcher: split 176L processEntry() into 5 focused methods; extract
  _resolveDescription() deduplicating 3 call sites for description acquisition
- bash-tool-parser: split 152L processCleanLine() into 4 handlers; extract
  _createActiveTool() factory and _scheduleAutoRemove() helper
- session: extract _setupOrAttachMuxSession() deduplicating ~80L between
  startInteractive/startShell; extract _handleTerminalOutput()
- respawn-controller: split 180L handleTerminalData() into 3 detection layers;
  data-driven validation loop replacing 9 individual calls
- plan-orchestrator: extract _extractJsonFromResponse(), _emitAgentFailure(),
  _formatResearchSection() helpers
- orchestrator-loop: extract _finalizeTask() unifying task completion/failure;
  _clearTimer() utility for correct clearInterval/clearTimeout dispatch
- ralph-status-parser: config-driven FIELD_PARSERS[] replacing 8 near-identical
  field-matching blocks; split updateCircuitBreaker() into focused handlers
- state-store: extract _mergeWithInitialState() and _resetCircuitBreaker()

Frontend:
- app.js: add _notifySession() helper used by 18 call sites across 5 modules
- panels-ui.js: extract _addActivityEntry() replacing 4 duplicate blocks
- settings-ui.js: extract _updateTunnelUrlRow() deduplicating 2 blocks
- ralph-panel.js, respawn-ui.js: convert to _notifySession()

Routes:
- route-helpers: add toggleService() helper
- system-routes: use toggleService() for watcher toggles; extract collectActiveTokens()
- orchestrator-routes: data-driven EVENT_MAP replacing 10 identical listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 22:50:25 +01:00
arkonandClaude Opus 4.6 ba09184efa fix: wizard "No JSON found" — Claude CLI stream-json returns empty result field
Claude CLI's --output-format stream-json now returns "result": "" in the result
message. The actual response text lives in assistant message text blocks, which
_textOutput correctly accumulates. runPrompt() was returning the empty
resultMsg.result without falling back to _textOutput.value.

Also improved plan-orchestrator JSON extraction to try code-block-wrapped JSON
first (```json {...} ```) before the greedy regex, plus debug logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:53:57 +01:00
arkonandClaude Opus 4.6 93719b41cd chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:34:14 +01:00
arkonandClaude Opus 4.6 a448983be3 refactor: pass 2 — extract shared helpers and simplify patterns
app.js:
- Add _clearTimer() helper replacing 11 inline clearTimeout patterns
- Add _isStaleSelect() helper for generation check + cleanup
- Replace 11 keyboard shortcut if-blocks with data-driven lookup table
- Extract _cleanupPreviousSession() from selectSession() (~75 lines)
- Extract _resetAllAppState() from handleInit() (~75 lines)

tmux-manager:
- Extract buildEnvExports() eliminating duplication in createSession/respawnPane
- Extract buildPathExport() for CLI path resolution
- Extract _configureOpenCode() for OpenCode setup

routes:
- Add readJsonConfig() to route-helpers, replacing 5 inline JSON-read patterns
- Add validateSessionFilePath() to route-helpers, replacing 2 identical path
  traversal validation blocks in file-routes

session-auto-ops:
- Convert executeWhenIdle() from 8 positional params to options object
- Extract validateThreshold() for shared compact/clear validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:32:28 +01:00
arkonandClaude Opus 4.6 3145eac6d9 refactor: extract helper methods to reduce duplication and improve readability
DRY up repeated patterns across 7 core files:
- state-store: extract serializeState() and split assembleStateJson() into 3 focused methods
- session: extract _resetBuffers(), _clearAllTimers(), _handleJsonMessage()
- ralph-tracker: extract completeAllTodos() (was 4x duplicated), emitValidationWarning(), similarity constants
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
- tmux-manager: replace 15 path.includes() checks with single UNSAFE_PATH_CHARS regex
- session-auto-ops: extract executeWhenIdle() shared retry helper for checkAutoCompact/checkAutoClear

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:21:38 +01:00
arkonandClaude Opus 4.6 e3c609f5f0 test: add coverage for lastUsedCase partial update and strict schema rejection
Tests that partial PUT /api/settings with just lastUsedCase works correctly
and that including modelConfig triggers strict Zod schema rejection (the bug
fixed in #49).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:39:47 +01:00
Tenggan Zhang 52e774f83c fix: case selection not persisting across page refresh (#49)
Thank you for the clean fix! The root cause analysis in the PR description was excellent — the strict Zod schema rejecting modelConfig during the GET-then-PUT pattern was a subtle bug.
2026-03-25 13:39:16 +01:00
arkon 82d08df53f chore: version packages 2026-03-25 00:18:14 +01:00
arkonandClaude Opus 4.6 2709b2fe49 feat: make buffer size limits configurable via environment variables
Allow overriding MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT_SIZE,
TRIM_TEXT_TO, and MAX_MESSAGES via CODEMAN_* env vars, falling back to existing
defaults. Enables users with fewer sessions or more RAM to tune buffer sizes
without patching source.

Closes #48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:40:40 +01:00
Ark0N b1d3b27e5b Merge pull request #47 from TeigenZhang/fix/mobile-cjk-input-and-layout
fix: mobile CJK input, terminal flicker, and layout overflow
2026-03-24 18:22:48 +01:00
Teigen 47963b54fa fix: mobile CJK input, terminal flicker, and layout overflow
Terminal flicker:
- Skip buffer-recovered/clear-terminal events during active buffer load
  to prevent competing clear+rewrite cycles (app.js)
- Move viewport+scrollback clear inside dimension-change guard so resize
  without actual SIGWINCH doesn't blank the terminal (terminal-ui.js)
- Sync _lastResizeDims on explicit resize to prevent redundant clears

CJK input rewrite (input-cjk.js):
- Use InputEvent.inputType to distinguish insertText (final) from
  insertCompositionText (tentative) — fixes Chinese punctuation and
  English text being swallowed during Android IME composition
- Remove isComposing guard on Enter so it always sends
- Phantom character (U+200B) keeps textarea non-empty so Android
  long-press backspace generates continuous deleteContentBackward
  events at the keyboard's native repeat rate

CJK input settings:
- Add "CJK Input" toggle in Settings > Input (index.html, settings-ui.js)
- Store as device-specific setting (cjkInputEnabled), not synced to server
- Replace INPUT_CJK_FORM env var dependency with user-controlled setting
  (env var still works as server override)

Mobile layout:
- Fix welcome screen overflow on phones by constraining .welcome-content
  to calc(100vw - 1.5rem) (mobile.css)
- Move xterm helper textarea on-screen for touch devices to fix iOS
  keyboard input (styles.css)
- Focus terminal synchronously in user-gesture context for iOS Safari
  keyboard activation (session-ui.js, app.js)
- Refocus terminal on tap (not scroll) in touch handler (terminal-ui.js)
2026-03-24 09:22:06 +08:00
arkonandClaude Opus 4.6 b7c3c30c8c fix: send Ctrl+L after tab switch to clear stale Ink CUP frames
Tailed terminal buffers contain multiple CUP-positioned Ink frames from
different time points. When replayed in xterm, old frames at viewport
positions not covered by the latest frame persist as ghost content
(e.g. duplicate "bypass permissions" bars). After buffer load, send
Ctrl+L via the session input API to trigger a full Ink redraw, which
overwrites all stale frame content with the correct current state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:59:48 +01:00
arkonandClaude Opus 4.6 0d80524f10 fix: prevent duplicate terminal output on tab switch to busy sessions
Two fixes for the tab-switching corruption bug:

1. _finishBufferLoad() now discards queued SSE events instead of flushing
   them. The loaded API buffer is the source of truth — queued events
   overlap with it, and flushing them writes duplicate Ink cursor-up
   redraws that corrupt the terminal display (garbled text, wrong cursor
   positions).

2. Skip stale cache write for busy sessions. When a session is actively
   working, the cache is always outdated — writing it first and then
   rewriting with the fresh API buffer caused a jarring double-render
   flash. Now busy sessions get a single clean clear+write transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:32:33 +01:00
arkonandClaude Opus 4.6 a9d83ec4e3 chore: version packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:55:39 +01:00
arkonandClaude Opus 4.6 84137cdba4 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:59:27 +01:00
arkonandClaude Opus 4.6 6eb3969816 fix: avoid no-control-regex lint error for ANSI strip pattern
Use RegExp constructor with String.raw to express \x1b without
a literal control character in the source, matching the pattern
used elsewhere in the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:57:36 +01:00
arkonandClaude Opus 4.6 de49437a6f docs: add browser-testing-guide to CLAUDE.md references, clarify route count
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:14 +01:00
arkonandClaude Opus 4.6 6a27639083 fix: increase Ink frame search window from 4KB to 64KB to prevent partial frames
Single Ink frames with response content can be 10-20KB, so the 4KB tail
was too small and caused blank gaps. Now searches the last 64KB for VPA
row drops to find the last complete frame boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:09 +01:00
arkonandClaude Opus 4.6 eb1b38c718 fix: align case select group height — stretch buttons to match dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:06:15 +01:00
arkonandClaude Opus 4.6 ea7b103b47 fix: prevent stale terminal data on tab switch — add chunkedTerminalWrite cancellation
chunkedTerminalWrite used requestAnimationFrame to write buffer chunks across
frames but had no cancellation. When switching tabs, old session's remaining
chunks continued writing stale data into the new session's terminal, causing
visual artifacts and garbled content.

- Add _chunkedWriteGen generation counter to abort in-flight chunked writes
- Bump gen early in selectSession() and SSE reconnect to immediately cancel
- Guard finish() so aborted writes don't flush SSE queue for wrong session
- Add fitAddon.fit() before buffer writes to sync terminal dimensions
- Add fitAddon.fit() in sendResize() to ensure local/server dim parity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:00:58 +01:00
arkonandClaude Opus 4.6 bec8e2f9ee fix: improve history prompt extraction — filter expanded commands, add tail scan fallback
Skip /init expansions, slash commands, orchestrator prompts, ANSI codes, secrets,
and short/vague messages. When head scan finds no usable prompt (e.g. /init sessions),
read last 32KB of transcript to find a recent meaningful user message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:42:50 +01:00
arkonandClaude Opus 4.6 2203f3a347 feat: visual redesign — glass morphism, refined colors, polished UI
Modernize the entire UI with a cohesive "refined dark glass" aesthetic
while preserving all existing functionality.

- Header/toolbar: backdrop-filter blur(16px), semi-transparent backgrounds
- Buttons: 6px radius, multi-stop gradients, inner glow, cubic-bezier transitions
- Welcome screen: gradient text title, radial bg glow, pill-shaped buttons with hover lift
- Panels/modals: glass backgrounds, 12px radius, layered shadows
- Color palette: cooler blue-tinted darks replacing flat blacks
- Forms: refined inputs with focus rings, glass toggle switches
- New CSS vars: --glass-bg, --glass-border, --btn-radius, --transition-smooth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:24:27 +01:00
arkonandClaude Opus 4.6 867a10d78a refactor: optimize history endpoint — reuse buffer, extract readFileHead, use line iterator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:01:56 +01:00
arkon e54d7badc4 chore: version packages 2026-03-22 01:57:49 +01:00
arkon 40dfac3534 feat: improve session history with first prompt + clickable monitor rows (closes #45) 2026-03-22 01:57:17 +01:00
arkonandClaude Opus 4.6 e899a43a18 chore: hide orchestrator button until feature is fully tested
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 01:48:11 +01:00
arkonandClaude Opus 4.6 0cab8a7ece fix: stop subagent monitor windows from auto-opening on discovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 01:44:28 +01:00
arkonandClaude Opus 4.6 7b7cf958c0 feat: add live progress during orchestrator plan generation
New SSE event orchestrator:planProgress streams phase/detail updates
from the planner to the frontend in real-time. The panel now shows
a scrollable log of planning steps instead of just "Generating plan..."

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:03:24 +01:00
arkonandClaude Opus 4.6 6e64ddd853 feat: add Orchestrator button to toolbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 19:59:06 +01:00
arkonandClaude Opus 4.6 afea91b92b fix: patch 3 production bugs found during deep audit
1. Post-phase verify timer leak — setTimeout for verifyCurrentPhase was
   never stored, so pause() couldn't cancel it. Timer now tracked in
   postPhaseTimer field and cleared in clearPhasePoll().

2. Event forwarding flag survives loop replacement — boolean
   eventForwardingAttached stayed true when a new loop was created,
   so the new loop never got SSE forwarding. Now tracks the loop
   instance reference instead of a boolean.

3. Replan stuck when no sessions — replanPhase() returned without
   setting up task handlers or polling when no idle sessions were
   available. Now starts polling so the queued task gets picked up
   when a session becomes idle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 19:23:37 +01:00
arkonandClaude Opus 4.6 9449a8f157 test: expand OrchestratorLoop coverage to 60 tests — 17 new deep paths
New coverage:
- Task failure & retry (handleTaskFailed retry when retries < 2)
- Phase error auto-retry (handlePhaseError when attempts < maxAttempts)
- Verification with actual criteria (verifier call, pass/fail flow)
- Verification failure → replan → retry cycle
- Max verification attempts → phase failure
- Multi-phase sequential advancement
- Compact between phases (writeViaMux('/compact'))
- Crash recovery from verifying/replanning/paused states
- Single-task vs multi-task prompt generation
- Team phase sendInput error handling
- Verification session fallback (no sessions → skip)
- taskAssigned, phaseCompleted, phaseFailed event emissions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 19:15:42 +01:00
arkonandClaude Opus 4.6 d322f17f73 test: add 43 deep integration tests for OrchestratorLoop state machine
Covers full lifecycle: start → plan → approve → execute → verify → complete.
Tests state transitions, event emissions, persistence/recovery, pause/resume,
skip/retry, team phase execution, error handling, and edge cases.

Also fixes bugs found during review:
- Route context snapshot: use getter for orchestratorLoop (was null forever)
- Event listener stacking: guard setupEventForwarding with boolean flag
- Replan completion: create tracked TaskQueue task instead of raw sendInput
- Pause cleanup: call cleanupTaskHandlers() on pause
- Phase timeout: add phaseTimeoutTimer enforcement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 15:33:02 +01:00
arkonandClaude Opus 4.6 61b5ec095c feat: add Orchestrator Loop — phased plan execution with team agents
Adds a new autonomous loop that accepts high-level goals, generates
phased execution plans via AI, and executes them step-by-step with
verification gates between phases.

Core components:
- OrchestratorLoop: state machine (idle→planning→approval→executing→verifying→completed)
- OrchestratorPlanner: plan generation via PlanOrchestrator, Kahn's algorithm phase grouping
- OrchestratorVerifier: phase verification (strict/moderate/lenient modes)
- Prompt templates for phase execution, team delegation, verification, replanning

API (10 endpoints):
- POST start/approve/reject/pause/resume/stop
- GET status/plan
- POST phase/:id/skip, phase/:id/retry

Frontend: orchestrator-panel.js with SSE-driven state, phase progress, task tracking

Tests: 22 tests (18 route + 4 unit), all passing. Typecheck/lint/format clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:20:18 +01:00
arkonandClaude Opus 4.6 497ca4891a fix: restore mobile terminal scrollback — use JS scrollLines() instead of broken native scroll
xterm.js DOM renderer doesn't populate .xterm-viewport's scroll area (the div
is empty, scrollHeight === clientHeight), so native CSS scrolling via
touch-action:pan-y and overflow-y:scroll had nothing to scroll. Desktop worked
only because the wheel handler called terminal.scrollLines() directly.

- Replace split mobile/desktop touch handlers with unified JS-driven handler
  that converts touch deltas to terminal.scrollLines() calls (with pixel
  accumulation for slow swipes and momentum scrolling)
- Change touch-action from pan-y to none on terminal elements so browser
  doesn't fight the JS handler
- Remove now-unnecessary xterm-viewport position/overflow/z-index overrides
  and iOS -webkit-overflow-scrolling rules
- Fix _shrinkPaddingToFit() arithmetic (was adding gap instead of subtracting)
- Minor: add route-helpers.ts to CLAUDE.md, fix sse-events.ts comment count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:38:18 +01:00
arkon 580b7a3f90 chore: version packages 2026-03-19 12:35:39 +01:00
arkonandClaude Opus 4.6 34c3d8f5ff fix: tighten mobile keyboard layout — eliminate dead space and toolbar overlap
- Remove redundant 50px CSS padding on terminal-container when keyboard visible
- Reduce JS paddingBottom constant from +94 to +84 (exact toolbar + accessory)
- Add _shrinkPaddingToFit() to eliminate terminal row quantization gap
- Add CSS padding-bottom on .main for fixed toolbar clearance (keyboard hidden)
- Match iOS Safari toolbar offset (100vh - --app-height) in .main padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:35:08 +01:00
arkonandClaude Opus 4.6 2491471ba5 fix: prevent mobile page scroll when typing with keyboard open
iOS Safari scrolls the document to bring xterm's hidden textarea into
view when the user types, pushing the entire UI off-screen. Fix with:
- CSS position:fixed on .app when keyboard is visible
- window.scroll listener to reset scroll position as safety net
- scroll reset in onKeyboardShow before and after fit/resize

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:47:18 +01:00
arkonandClaude Opus 4.6 0c4aac8029 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:05:16 +01:00
arkonandClaude Opus 4.6 d436c6375f fix: strip Ink spinner bloat from terminal buffer before tailing
During long thinking phases, Ink's TUI rewrites the spinner/status bar
thousands of times via absolute cursor positioning (VPA/CUP). These
500KB+ of redraw frames pushed real content out of the 128KB tail
window, making the terminal appear empty when switching tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:55:24 +01:00
arkonandClaude Opus 4.6 e96baf9f66 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:42:16 +01:00
arkon 7b8aa529f2 chore: version packages 2026-03-15 03:52:30 +01:00
arkonandClaude Opus 4.6 0ad4e0ea24 fix: correct resolveCasePath priority order and suppress JSON parse warnings
- resolveCasePath now checks linked cases first (matching original behavior
  of /api/cases/:name and /api/cases/:name/fix-plan handlers)
- readLinkedCases only warns on real I/O errors, not JSON parse errors
  (SyntaxError has no .code property, so check for .code existence first)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 03:50:45 +01:00
arkonandClaude Opus 4.6 6bc403d88d refactor: clean up case routes DRY violations, remove dead export, standardize reply API
- Extract readLinkedCases() helper and resolveCasePath() to eliminate 6x duplicated
  linked-cases.json path construction and 5x duplicated file read/parse logic
- Replace O(n) .some() duplicate check with O(1) Set.has() in case listing
- Un-export isError() in types/api.ts (only used internally by getErrorMessage)
- Standardize reply.status() → reply.code() in system-routes (Fastify canonical API)
- Update CLAUDE.md: accurate frontend module listing, SSE event count (~106)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 03:47:18 +01:00
arkon 1ad05a5a42 chore: version packages 2026-03-14 22:22:52 +01:00
arkonandClaude Opus 4.6 192690911f refactor: extract app.js into 6 domain modules with deferred init
Split the monolithic app.js (~12.5K lines) into 6 focused mixin modules
that extend CodemanApp.prototype via Object.assign:

- terminal-ui.js — terminal setup, rendering pipeline, controls
- respawn-ui.js — respawn banner, countdown, presets, run summary
- ralph-panel.js — Ralph state panel, fix_plan, plan versioning
- settings-ui.js — app settings, visibility, web push, tunnel/QR, help
- panels-ui.js — subagent panel, teams, insights, file browser, log viewer
- session-ui.js — quick start, session options, case settings

Fix deferred script init ordering: wrap CodemanApp instantiation in
DOMContentLoaded so all defer'd mixin modules execute their
Object.assign before the constructor runs. Without this, init() calls
methods like applyHeaderVisibilitySettings() that don't exist yet.

Guard missing cleanupWizardDragging() call in subagent-windows.js.
Update build.mjs to minify/hash all new modules. Update CLAUDE.md
with new frontend architecture and load order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:24:22 +01:00
arkon 551461cb31 chore: version packages 2026-03-14 19:26:09 +01:00
arkonandClaude Opus 4.6 c4bae75c59 fix: add onerror handler for lazy-loaded WebGL addon script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:23:15 +01:00
arkonandClaude Opus 4.6 88c415fc37 perf: V8 compile cache, lazy-load WebGL, preload hints, batch tmux reconciliation
- Enable NODE_COMPILE_CACHE in systemd service and npm start for 10-20% faster cold starts
- Lazy-load xterm-addon-webgl.min.js (244KB) only on desktop — mobile never downloads it
- Add <link rel="preload"> hints for critical scripts (xterm, constants, app) in <head>
- Replace per-session tmux subprocess calls with single batch `list-panes -a` call
  (N*2+1+M execSync calls → 1 for reconcileSessions)
- Fix CLAUDE.md frontend module count (10 → 11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:20:44 +01:00
arkonandClaude Opus 4.6 7175e4b350 docs: update CLAUDE.md and README.md to reflect current codebase
Correct stale counts and add missing entries: route modules 12→13
(ws-routes.ts), frontend modules 9→10 (input-cjk.js), handler count
~111→~114, utilities section expanded, TypeScript badge 5.5→5.9,
frontend extracted modules 8→9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:48:25 +01:00
arkonandClaude Opus 4.6 08a417997f ci: upgrade actions/checkout and actions/setup-node to v6 (Node 24)
Replace v4 (Node 20) with v6 (Node 24 native) to eliminate the
deprecation warning. Remove the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24
workaround since v6 doesn't need it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:42:22 +01:00
arkonandClaude Opus 4.6 e6cb89b0cd ci: use Node.js 24 runtime for actions and bump release node to 22
Opt into Node.js 24 for GitHub Actions runners (actions/checkout@v4,
actions/setup-node@v4) to silence deprecation warnings. Also bump
release.yml from node 20 to 22 to match ci.yml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:40:44 +01:00
arkon d072e773d8 chore: version packages 2026-03-14 18:38:28 +01:00
arkonandClaude Opus 4.6 a649c91b68 fix: WS session lifecycle, reconnection, and CJK session-switch cleanup
- Close WebSocket when session exits (exit event listener) to prevent
  orphaned listeners and stale writes to dead PTY
- Add readyState guard in onTerminal to stop buffering after socket closes
- Simplify heartbeat: remove redundant alive flag, use pongTimeout only
- Add exponential backoff reconnection on unexpected WS close (skip for
  server rejections 4004/4008/4009)
- Clear CJK textarea on session switch to prevent wrong-session input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:37:10 +01:00
arkonandClaude Opus 4.6 3383c23099 fix: address code review findings across WS, CJK input, install.sh, and README
WebSocket route: add socket error handler to prevent process crashes, enforce
per-session connection limit (max 5), track/decrement counts on close.

CJK input: add destroy() method with proper listener cleanup, guard against
double-init, add maxlength/aria-label to textarea, use language-neutral
placeholder, explicitly clear cjkActive on hide.

install.sh: fix update() to use $BRANCH and $REPO_URL instead of hardcoded
origin/master — fork users were silently switched back to master on update.

README: fix broken markdown table (paragraph concatenated into last cell),
add CODEMAN_NODE_VERSION to env var table.

Tests: add 8 new test cases for batch coalescing, flush threshold, unknown
message types, connection limit, heartbeat, readyState guards. Import
MAX_INPUT_LENGTH from config, add connectWs timeout, replace setTimeout
with vi.waitFor in cleanup test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:27:58 +01:00
arkonandClaude Opus 4.6 c3e1e731ef fix: use generic placeholders in fork install README example
Replace hardcoded contributor fork URL with <user>/<branch> placeholders
so the documentation is useful for any contributor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:11:43 +01:00
Ark0N 405b711c3a Merge pull request #41 from douchekr/feat/input-cjk-form
feat: add CJK IME input textarea and fork/branch install support
2026-03-14 18:10:09 +01:00
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
jayparkandClaude Opus 4.6 393a2d9c28 fix: use BRANCH variable in install.sh no-changes update path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:52:28 +09:00
jayparkandClaude Opus 4.6 809bf6a614 fix: use BRANCH variable in install.sh update path
The update path was hardcoded to origin/master. Now uses the
CODEMAN_BRANCH variable and updates the remote URL on upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:51:20 +09:00
jayparkandClaude Opus 4.6 da71d8d01c feat: support custom repo URL and branch in install.sh
Add CODEMAN_REPO_URL and CODEMAN_BRANCH env vars to install.sh
for installing from forks or feature branches. Update README with
fork installation instructions and env var reference table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:50:06 +09:00
jayparkandClaude Opus 4.6 e5aca6aa4c feat: add CJK IME input textarea with env toggle
Add a dedicated textarea below the terminal for CJK (Korean/Japanese/Chinese)
IME input. xterm.js intercepts IME composition events, preventing composed
characters from displaying correctly. This textarea bypasses xterm entirely
by using native browser IME handling — text accumulates until Enter, then
sends to PTY in one shot.

- Always-visible textarea below terminal (inside .terminal-wrap flex column)
- focus/blur sets window.cjkActive flag to block xterm onData
- Enter sends textarea.value + \r to PTY, Escape clears
- Arrow keys, Ctrl+C/D/L/Z, Tab, Backspace pass through to PTY when empty
- attachCustomKeyEventHandler suppresses xterm key handling during composition
- INPUT_CJK_FORM=ON|OFF env var toggle (default: off, passed via SSE init)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:23:25 +09: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
132 changed files with 29658 additions and 13742 deletions
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
+3 -3
View File
@@ -16,12 +16,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org
+218
View File
@@ -1,5 +1,223 @@
# aicodeman
## 0.5.6
### Patch Changes
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
## 0.5.5
### Patch Changes
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
## 0.5.4
### Patch Changes
- Fix terminal flicker regression — re-add server-side DEC 2026 synchronized output wrapping around batched terminal data. Ink spinner frames (cursor-up + redraw cycles) do not emit their own DEC 2026 markers, so without the server wrapper each partial cursor update rendered individually causing visible flicker. Also: extract SSE stream management, session listener wiring, and respawn event wiring from server.ts into dedicated modules; deduplicate error message extraction across 7 files with shared getErrorMessage() helper; update SSE event count in CLAUDE.md (106 → 117).
## 0.5.3
### Patch Changes
- Readability refactor across 12 core files, extracting ~35 helper methods to reduce duplication:
- state-store: extract serializeState(), split assembleStateJson() into focused sub-methods
- session: extract \_resetBuffers() (3x dedup), \_clearAllTimers() (10 timer cleanups), \_handleJsonMessage()
- ralph-tracker: extract completeAllTodos() (4x dedup), emitValidationWarning(), named similarity constants
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
- tmux-manager: replace 15 path.includes() with UNSAFE_PATH_CHARS regex, extract buildEnvExports/buildPathExport/\_configureOpenCode helpers
- session-auto-ops: extract executeWhenIdle() shared retry helper, convert to options object, add validateThreshold()
- app.js: add \_clearTimer() (11 call sites), \_isStaleSelect(), keyboard shortcut lookup table, \_cleanupPreviousSession(), \_resetAllAppState()
- route-helpers: add readJsonConfig() (5 inline patterns replaced), validateSessionFilePath() (2 duplicated blocks replaced)
## 0.5.2
### Patch Changes
- Make buffer size limits configurable via CODEMAN\_\* environment variables (MAX_TERMINAL_BUFFER, TRIM_TERMINAL_TO, MAX_TEXT_OUTPUT, TRIM_TEXT_TO, MAX_MESSAGES), falling back to existing defaults. Allows users with fewer sessions or more RAM to tune buffer sizes without patching source.
Fix duplicate terminal output on tab switch to busy sessions by clearing the terminal before writing the new buffer.
Fix stale Ink CUP frames after tab switch by sending Ctrl+L to force a clean redraw.
Fix mobile CJK input handling: resolve textarea positioning, terminal flicker during composition, and layout overflow on small screens. Improve CJK composition lifecycle with better event handling and fallback flush timers.
## 0.5.1
### Patch Changes
- refactor: codebase cleanup — extract route helpers, eliminate boilerplate, optimize hot paths
- Add `parseBody()` helper to route-helpers.ts: validates request body against Zod schema with structured 400 error on failure, replacing 37 identical safeParse + error-check blocks across 10 route files
- Add `persistAndBroadcastSession()` helper: combines persist + SessionUpdated broadcast into one call, replacing 5 repeated 2-line pairs
- Migrate session-routes.ts to use `findSessionOrFail()` consistently (17 inline session lookups replaced) and `parseBody()` (12 patterns)
- Migrate ralph-routes.ts to use `findSessionOrFail()` (9 lookups) and `parseBody()` (4 patterns)
- Migrate 8 remaining route files to use `parseBody()` (21 patterns total)
- Fix O(n log n) eviction in bash-tool-parser.ts: replace `Array.from().sort()[0]` with O(n) min-scan for oldest active tool
- Extract `_debouncedCall()` utility in frontend: replaces 4 manual debounce patterns (7 lines each → 1 line) in app.js, panels-ui.js, ralph-panel.js
- Net reduction: 208 lines removed across 16 files
## 0.5.0
### Minor Changes
- Visual redesign with glass morphism, refined colors, and polished UI. Optimize history endpoint with buffer reuse and line iterator. Fix Ink frame search window (4KB→64KB) to prevent partial frames. Fix stale terminal data on tab switch via chunkedTerminalWrite cancellation. Improve history prompt extraction with expanded command filtering and tail scan fallback. Align case select group height to match dropdown. Fix no-control-regex lint error for ANSI strip pattern. Add browser-testing-guide to CLAUDE.md references.
## 0.4.7
### Patch Changes
- feat: improve session navigability in history and monitor panel (closes #45)
- History items now show the first user prompt as the title with the project path as a subtitle, making it much easier to distinguish sessions from the same project
- The `/api/history/sessions` endpoint extracts the first user message from each transcript JSONL, stripping system-injected XML tags and command artifacts, truncating to 120 chars
- Monitor panel session rows are now clickable — clicking navigates directly to that session's tab via `selectSession()`; Kill button retains independent behavior via `stopPropagation()`
- Updated CLAUDE.md architecture tables to reflect Orchestrator Loop additions (14 route modules, 15 type files, orchestrator domain files, orchestrator-panel.js frontend module)
- fix: stop subagent monitor windows from auto-opening on discovery
- feat: add Orchestrator Loop with phased plan execution, live progress during plan generation, and toolbar button (hidden until fully tested)
- fix: patch 3 production bugs found during deep audit
- fix: restore mobile terminal scrollback using JS scrollLines() instead of broken native scroll
## 0.4.6
### Patch Changes
- Fix mobile keyboard scroll and layout issues:
- Prevent iOS Safari from scrolling the page when typing with the keyboard open (position:fixed on .app + window.scroll reset)
- Eliminate dead space between terminal and keyboard accessory bar by removing redundant CSS padding, tightening JS padding constant, and adding row quantization gap compensation
- Fix toolbar overlapping terminal content when keyboard is hidden by adding proper padding-bottom to .main, including iOS Safari bottom bar offset
- Strip Ink spinner bloat from terminal buffer before tailing
- Fix resolveCasePath priority order and suppress JSON parse warnings
## 0.4.5
### Patch Changes
- Fix mobile keyboard toolbar positioning on iOS Safari: toolbar (Run/Stop/Run Shell) was hidden behind the accessory bar when virtual keyboard was active due to overlapping CSS positions. Remove the aggressive safety check in `updateLayoutForKeyboard()` that incorrectly dismissed keyboard state when iOS scrolled the visual viewport during typing. Add Safari-bar CSS offset to accessory bar so it properly stacks above the toolbar. Remove the double-counted Safari-bar offset when keyboard is visible since the JS transform already covers the full distance.
## 0.4.4
### Patch Changes
- fix: mobile keyboard hides terminal content on iPhone
Fixed a bug where opening the virtual keyboard on iPhone left zero visible terminal space. Two independent mechanisms were both accounting for the keyboard height: `MobileDetection.updateAppHeight()` shrunk `--app-height` to the visual viewport height, while `KeyboardHandler.updateLayoutForKeyboard()` added a large `paddingBottom`. These double-counted, leaving negative space for the terminal (user saw accessory bar + toolbar but no terminal content).
Fix: `updateAppHeight()` now skips when the keyboard is visible, and `handleViewportResize()` restores `--app-height` to the pre-keyboard value on first detection (since MobileDetection's listener fires before KeyboardHandler's). On keyboard close, `--app-height` is re-synced to the current visual viewport.
## 0.4.3
### Patch Changes
- Refactor case routes: extract readLinkedCases() and resolveCasePath() helpers to eliminate 6x duplicated linked-cases.json path construction and 5x duplicated file read/parse logic. Replace O(n) .some() duplicate check with O(1) Set.has() in case listing. Un-export unused isError() type guard. Standardize reply.status() to reply.code() in system routes. Update CLAUDE.md frontend module listing and SSE event count.
## 0.4.2
### Patch Changes
- Extract monolithic app.js (~12.5K lines) into 6 focused domain modules that extend CodemanApp.prototype via Object.assign: terminal-ui.js (terminal setup, rendering pipeline, controls), respawn-ui.js (respawn banner, countdown, presets, run summary), ralph-panel.js (Ralph state panel, fix_plan, plan versioning), settings-ui.js (app settings, visibility, web push, tunnel/QR, help), panels-ui.js (subagent panel, teams, insights, file browser, log viewer), session-ui.js (quick start, session options, case settings). Fix critical deferred script init ordering bug: wrap CodemanApp instantiation in DOMContentLoaded so all defer'd mixin modules execute their Object.assign before the constructor runs. Guard missing cleanupWizardDragging() call in subagent-windows.js. Update build.mjs to minify/hash all new modules.
## 0.4.1
### Patch Changes
- Performance optimizations: V8 compile cache for 10-20% faster cold starts, lazy-load WebGL addon (244KB saved on mobile), preload hints for critical scripts, batch tmux reconciliation (N subprocess calls → 1). Also: WebSocket session lifecycle fixes, CJK IME input support, CI upgrade to Node 24/actions v6, install.sh fork support, and CLAUDE.md/README documentation refresh.
## 0.4.0
### Minor Changes
- Add CJK IME input textarea for xterm.js terminal (env toggle INPUT_CJK_FORM=ON). Always-visible textarea below terminal handles native browser IME composition, forwarding completed text to PTY on Enter. Supports arrow keys, Ctrl combos, backspace passthrough, and Escape to clear.
Add fork installation support to install.sh with CODEMAN_REPO_URL and CODEMAN_BRANCH env vars, allowing custom repository and branch for git clone/update operations. README updated with fork installation instructions.
Fix WebSocket session lifecycle: close WS connections when session exits (prevents orphaned listeners and stale writes to dead PTY), add readyState guard in onTerminal to stop buffering after socket closes, simplify heartbeat by removing redundant alive flag.
Add WebSocket reconnection with exponential backoff (1s-10s) on unexpected close, skipping server rejection codes (4004/4008/4009). Falls back gracefully to SSE+POST during reconnection.
Clear CJK textarea on session switch to prevent sending stale text to wrong session.
## 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
+15 -15
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Task | Command |
|------|---------|
| Dev server | `npx tsx src/index.ts web` |
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
| Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
@@ -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.5 (must match `package.json`)
**Version**: 0.5.6 (must match `package.json`)
## Project Overview
@@ -78,9 +78,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux).
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Does not lint `app.js` or `scripts/**/*.mjs`.
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
## Common Gotchas
@@ -98,18 +98,19 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Domain | Key files | Notes |
|--------|-----------|-------|
| **Entry** | `src/index.ts`, `src/cli.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts` | |
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | |
| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
@@ -118,7 +119,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
### Data Flow
@@ -143,7 +144,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `app.js`(6) → `ralph-wizard.js`(7) → `api-client.js`(8) → `subagent-windows.js`(9).
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
@@ -166,11 +167,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~100 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
~111 handlers across 13 route files in `src/web/routes/`: system (35), sessions (24), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1). Each file has `@fileoverview` with endpoint details.
~124 handlers across 14 route files in `src/web/routes/`: system (36), sessions (25), orchestrator (10), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -179,7 +180,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`
- **New test**: Pick unique port (search `const PORT =`). Integration: ports 3099-3211. Route tests: `app.inject()` — see `test/routes/_route-test-utils.ts`.
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
@@ -225,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
## References
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`.
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`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
## Scripts
@@ -238,7 +239,6 @@ Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel sta
## Common Workflows
**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`.
**API endpoint**: Types in `src/types/*.ts` → route in `src/web/routes/*-routes.ts` → SSE event if needed → handle in `app.js`.
**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`.
## Tunnel
+28 -9
View File
@@ -11,7 +11,7 @@
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.5-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.5"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-1435%20total-22c55e?style=flat-square" alt="Tests">
</p>
@@ -28,18 +28,33 @@
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
```
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
**Install from a fork or specific branch:**
```bash
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
CODEMAN_BRANCH=<branch> bash
```
The installer supports these environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
| `CODEMAN_BRANCH` | `master` | Git branch to install |
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
```bash
codeman web
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
```
**Update to latest version:**
```bash
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash -s update
```
<details>
<summary><strong>Run as a background service</strong></summary>
@@ -143,6 +158,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.
@@ -480,9 +499,9 @@ The codebase went through a comprehensive 7-phase refactoring that eliminated go
| Phase | What changed | Impact |
|-------|-------------|--------|
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
| **Route extraction** | `server.ts` split into 12 domain route modules + auth middleware + port interfaces | **−60%** server.ts LOC (6,736 → 2,697) |
| **Route extraction** | `server.ts` split into 13 domain route modules + auth middleware + port interfaces | **−60%** server.ts LOC (6,736 → 2,697) |
| **Domain splitting** | `types.ts` → 14 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
| **Frontend modules** | `app.js` → 8 extracted modules (constants, mobile, voice, notifications, keyboard, API, subagent windows) | **−24%** app.js LOC (15.2K → 11.5K) |
| **Frontend modules** | `app.js` → 9 extracted modules (constants, mobile, voice, notifications, keyboard, CJK input, API, Ralph wizard, subagent windows) | **−24%** app.js LOC (15.2K → 11.5K) |
| **Config consolidation** | ~70 scattered magic numbers → 9 domain-focused config files | Zero cross-file duplicates |
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

+367
View File
@@ -0,0 +1,367 @@
# Orchestrator Loop — Architecture & Data Flow
> Technical architecture document. Not for GitHub.
## System Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ CODEMAN WEB UI │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Orchestrator Dashboard │ │
│ │ [Goal Input] [Plan View] [Phase Progress] [Agent Activity] │ │
│ └───────────────────────────┬──────────────────────────────────┘ │
│ │ SSE Events │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Orchestrator API Routes (/api/orchestrator/*) │ │
│ └───────────────────────────┬──────────────────────────────────┘ │
└───────────────────────────────┼─────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR LOOP │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Orchestrator │ │ Orchestrator │ │ Orchestrator │ │
│ │ Planner │ │ Loop (state │ │ Verifier │ │
│ │ │ │ machine) │ │ │ │
│ │ • Research │◄──►│ • Phase mgmt │◄──►│ • Test runner │ │
│ │ • Plan gen │ │ • Task queue │ │ • AI review │ │
│ │ • Phasing │ │ • Event loop │ │ • Output checks │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ EXISTING CODEMAN INFRASTRUCTURE │ │
│ │ │ │
│ │ SessionManager ←→ Sessions ←→ PTY (Claude CLI) │ │
│ │ ↑ ↑ ↑ │ │
│ │ │ │ │ │ │
│ │ TaskQueue RalphTracker RespawnController │ │
│ │ StateStore HooksConfig TeamWatcher │ │
│ │ Auto-Ops SubagentWatcher SSE Broadcast │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## Data Flow: Complete Lifecycle
### 1. User Submits Goal
```
User → POST /api/orchestrator/start { goal: "Build a REST API...", config: {...} }
→ OrchestratorLoop.start(goal)
→ state = PLANNING
→ emit('stateChanged', 'planning')
→ SSE: orchestrator:stateChanged
```
### 2. Planning Phase
```
OrchestratorPlanner.generatePlan(goal)
→ PlanOrchestrator.generateDetailedPlan(goal)
→ [Research Agent] → enriched task description
→ [Planner Agent] → PlanItem[]
→ groupIntoPhases(planItems)
→ topological sort by dependencies
→ group into layers
→ assign team strategies
→ OrchestratorPlan { phases: [...] }
→ state = APPROVAL
→ emit('planReady', plan)
→ SSE: orchestrator:planReady
```
### 3. User Approves Plan
```
User → POST /api/orchestrator/approve
→ OrchestratorLoop.approvePlan()
→ state = EXECUTING
→ executePhase(phases[0])
```
### 4. Phase Execution
```
executePhase(phase)
→ For each task in phase:
→ Convert to CreateTaskOptions
→ Add to TaskQueue with completion phrase "PHASE_{N}_TASK_{M}_DONE"
→ If phase.teamStrategy.type === 'team':
→ Start session with AGENT_TEAMS enabled
→ Send team orchestration prompt to lead
→ Else:
→ Assign tasks to available sessions (same as RalphLoop)
→ Listen for task completion events:
→ TaskQueue emits taskCompleted
→ Check: all phase tasks done?
→ Yes → state = VERIFYING → verifyPhase(phase)
→ No → wait for more completions
```
### 5. Verification
```
verifyPhase(phase)
→ OrchestratorVerifier.verify(phase, session)
→ Run test commands via session
→ Check file existence
→ AI review (optional)
→ If passed:
→ phase.status = 'passed'
→ emit('phaseCompleted', phase)
→ If more phases: executePhase(nextPhase)
→ If last phase: state = COMPLETED
→ If failed:
→ phase.attempts++
→ If attempts < maxAttempts:
→ state = REPLANNING
→ Generate recovery tasks
→ state = EXECUTING (retry)
→ Else:
→ state = FAILED
→ emit('phaseFailed', phase, reason)
```
### 6. Context Management Between Phases
```
After phase completion:
→ If config.compactBetweenPhases:
→ session.sendInput('/compact')
→ Wait for compact to complete
→ If config.respawnBetweenMilestones && phase is a milestone:
→ Save orchestrator state to StateStore
→ Respawn session (kill + recreate)
→ Send resume prompt with phase context
```
## File Layout
```
src/
├── orchestrator-loop.ts # Main state machine (~400 lines)
├── orchestrator-planner.ts # Plan generation + phase grouping (~300 lines)
├── orchestrator-verifier.ts # Phase verification (~200 lines)
├── types/
│ └── orchestrator.ts # All orchestrator types (~150 lines)
├── prompts/
│ └── orchestrator.ts # Prompt templates (~200 lines)
├── web/
│ ├── routes/
│ │ └── orchestrator-routes.ts # API endpoints (~250 lines)
│ └── public/
│ └── orchestrator-ui.js # Frontend panel (~500 lines)
```
## Integration Points with Existing Code
### StateStore (`src/state-store.ts`)
```typescript
// Add to AppState interface
orchestrator?: OrchestratorPersistState;
// Add methods
getOrchestratorState(): OrchestratorPersistState;
setOrchestratorState(state: Partial<OrchestratorPersistState>): void;
```
### SSE Events (`src/web/sse-events.ts`)
```typescript
// Add ~8 new events
export const SseEvent = {
// ... existing
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
ORCHESTRATOR_VERIFICATION: 'orchestrator:verificationResult',
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
} as const;
```
### Frontend Constants (`src/web/public/constants.js`)
```javascript
// Mirror SSE events
SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED = 'orchestrator:stateChanged';
// ... etc
```
### Route Registration (`src/web/routes/index.ts`)
```typescript
import { registerOrchestratorRoutes } from './orchestrator-routes.js';
// Add to barrel export
```
### Server (`src/web/server.ts`)
```typescript
// Initialize OrchestratorLoop alongside RalphLoop
const orchestratorLoop = new OrchestratorLoop(config);
// Register routes
registerOrchestratorRoutes(app, { ...ctx, orchestrator: orchestratorLoop });
```
### Port Interface (`src/web/ports/`)
```typescript
// New port
export interface OrchestratorPort {
orchestrator: OrchestratorLoop;
}
```
## Prompt Flow Through System
The key insight is how prompts flow from Orchestrator → Session → Claude:
```
OrchestratorLoop decides to execute Phase 3, Task 2
│
▼
Converts OrchestratorTask to CreateTaskOptions:
{
prompt: "Implement the rate limiter middleware. Read src/middleware/auth.ts
for the pattern. Add to src/middleware/rate-limiter.ts. Must export
a Fastify plugin. When done: <promise>PHASE_3_TASK_2_DONE</promise>",
priority: 100,
dependencies: ["phase-3-task-1"], // Must finish auth middleware first
completionPhrase: "PHASE_3_TASK_2_DONE",
timeoutMs: 600000 // 10 minutes
}
│
▼
TaskQueue.addTask(options)
│
▼
RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignment
│
▼
session.sendInput(task.prompt)
│
▼
writeViaMux() → tmux send-keys -l "prompt..." + Enter
│
▼
Claude CLI receives prompt, executes, outputs results
│
▼
RalphTracker.processData() → detects "PHASE_3_TASK_2_DONE"
│
▼
emit('completionDetected') → OrchestratorLoop.handleTaskCompleted()
│
▼
Check: all tasks in Phase 3 done? → If yes → verifyPhase(phase3)
```
## Team Agent Flow (When Enabled)
```
Phase has teamStrategy.type === 'team'
│
▼
OrchestratorLoop creates/reuses a session with:
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
│
▼
Sends team orchestration prompt:
"You're the team lead for Phase 3: Core Implementation.
Your team should work on these tasks in parallel:
1. Rate limiter middleware (teammate 1)
2. Error handling middleware (teammate 2)
3. Validation layer (teammate 3)
Context files to read first: [...]
Each teammate should output their task's completion phrase when done.
When ALL tasks are complete, output: <promise>PHASE_3_COMPLETE</promise>"
│
▼
Claude Code team-lead spawns teammates
│
▼
TeamWatcher detects new team in ~/.claude/teams/
→ Matches to session via leadSessionId
→ Tracks teammate activity
│
▼
Teammates work in parallel (in-process threads)
│
▼
hook: teammate_idle → POST /api/hook-event
→ OrchestratorLoop notes teammate finished
│
▼
hook: task_completed → POST /api/hook-event
→ Or: RalphTracker detects PHASE_3_COMPLETE
→ OrchestratorLoop → phase complete → verify
```
## Error Recovery Strategy
```
Task fails (timeout, error, session crash)
│
├─ Task-level retry (up to 2 retries per task)
│ → Reset task to pending
│ → Re-queue with modified prompt: "Previous attempt failed: {error}. Try again..."
│
├─ Phase-level retry (up to 3 retries per phase)
│ → Respawn session (fresh context)
│ → Re-execute entire phase with learnings from failure
│ → Modified prompt includes what went wrong
│
└─ Orchestration-level failure
→ All retries exhausted
→ state = FAILED
→ Notify user with detailed failure report
→ User can: modify plan → retry, skip phase → continue, or stop
```
## Interaction with Ralph Loop
Ralph Loop and Orchestrator Loop are **mutually exclusive** on the same sessions:
```
if (orchestratorLoop.isRunning()) {
// Orchestrator controls task assignment
// Ralph Loop should not interfere
// Respawn Controller uses 'orchestrator' preset
}
if (ralphLoop.isRunning()) {
// Ralph controls task assignment
// Orchestrator should not start
}
```
The Orchestrator can optionally USE the Ralph Loop internally for phase execution (delegate phase tasks to Ralph's queue), or manage task assignment directly. Decision: **manage directly** — gives more control over phase boundaries and verification timing.
## Summary of What Touches What
| Existing File | Change |
|---|---|
| `src/types/index.ts` | Export orchestrator types |
| `src/state-store.ts` | Add orchestrator state persistence |
| `src/web/sse-events.ts` | Add ~8 orchestrator events |
| `src/web/routes/index.ts` | Register orchestrator routes |
| `src/web/server.ts` | Initialize OrchestratorLoop |
| `src/web/public/constants.js` | Mirror SSE events |
| `src/web/public/app.js` | Add orchestrator event listeners, panel toggle |
| `src/web/route-helpers.ts` | Add 'orchestrator' respawn preset |
| New File | Purpose |
|---|---|
| `src/orchestrator-loop.ts` | Core state machine |
| `src/orchestrator-planner.ts` | Plan generation + phasing |
| `src/orchestrator-verifier.ts` | Phase verification |
| `src/types/orchestrator.ts` | Type definitions |
| `src/prompts/orchestrator.ts` | Prompt templates |
| `src/web/routes/orchestrator-routes.ts` | API endpoints |
| `src/web/public/orchestrator-ui.js` | Frontend panel |
| `src/web/ports/orchestrator-port.ts` | Port interface |
+633
View File
@@ -0,0 +1,633 @@
# Orchestrator Loop — Detailed Implementation Plan (v2)
> Internal research/planning document. Not for GitHub.
## Vision
The **Orchestrator Loop** is a new autonomous execution mode that transforms high-level user goals into phased, verified, team-coordinated implementations. Unlike Ralph Loop (flat task queue → idle sessions), the Orchestrator manages the full lifecycle: **plan → approve → execute → verify → adapt → complete**.
```
USER: "Add OAuth2 login with Google/GitHub, role-based access control, and API key management"
ORCHESTRATOR:
Phase 1: Research & Setup ✅ (3m) — scaffold, deps, config
Phase 2: Auth Core ✅ (8m) — OAuth2 flow, session mgmt
Phase 3: Provider Integration 🔄 (12m) — Google + GitHub (parallel via team agents)
Phase 4: RBAC ⏳ — roles, permissions, middleware
Phase 5: API Keys ⏳ — generation, validation, rate limits
Phase 6: Testing & Review ⏳ — integration tests, security review
Progress: ━━━━━━━━━━━━━━━━━━━━ 40% | Agents: 3 active | Time: 23m
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ OrchestratorLoop │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │ Orchestrator │ │ Orchestrator │ │ Orchestrator │ │
│ │ Planner │ │ Executor │ │ Verifier │ │
│ │ │ │ │ │ │ │
│ │ PlanOrchestrator│ │ TaskQueue │ │ AI review │ │
│ │ + phase grouper│ │ SessionManager │ │ Test commands │ │
│ │ + team strategy│ │ Team prompts │ │ File checks │ │
│ └───────┬────────┘ └───────┬────────┘ └─────────┬────────┘ │
│ │ │ │ │
│ └───────────────────┼──────────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Existing Codeman │ │
│ │ Infrastructure │ │
│ │ │ │
│ │ SessionManager │ │
│ │ TaskQueue │ │
│ │ RespawnController │ │
│ │ TeamWatcher │ │
│ │ PlanOrchestrator │ │
│ │ StateStore │ │
│ │ Hooks + SSE │ │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## State Machine
```
┌─────────┐
│ IDLE │
└────┬────┘
│ start(goal)
▼
┌─────────┐
┌────────│PLANNING │────────┐
│ fail └────┬────┘ │
▼ │ plan ready │ user cancels
┌────────┐ ▼ ▼
│ FAILED │ ┌─────────┐ ┌────────┐
└────────┘ │APPROVAL │ │ IDLE │
▲ └────┬────┘ └────────┘
│ │ approve
│ ▼
│ ┌──────────┐
│ ┌───►│EXECUTING │◄────────────────────┐
│ │ └────┬─────┘ │
│ │ │ all tasks in phase done │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │VERIFYING │ │
│ │ └────┬─────┘ │
│ │ pass │ │ fail │
│ │ ▼ ▼ │
│ │ more ┌──────────┐ │
│ │ phases?│REPLANNING│── retry ────────┘
│ │ │ └────┬─────┘
│ │ │ │ max retries
│ │ │ ▼
│ │ │ ┌────────┐
│ └────┘ │ FAILED │
│ next └────────┘
│ phase
│ │
│ ▼
│ ┌───────────┐
└─│ COMPLETED │
└───────────┘
```
**States:** `idle` | `planning` | `approval` | `executing` | `verifying` | `replanning` | `completed` | `failed` | `paused`
Transitions are event-driven. The state machine is the single source of truth — all methods check `this.state` before acting.
## Type Definitions
### `src/types/orchestrator.ts`
```typescript
// ═══════════════════════════════════════════════════════════════
// State Machine
// ═══════════════════════════════════════════════════════════════
export type OrchestratorState =
| 'idle'
| 'planning'
| 'approval'
| 'executing'
| 'verifying'
| 'replanning'
| 'completed'
| 'failed'
| 'paused';
// ═══════════════════════════════════════════════════════════════
// Plan Structure
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorPlan {
id: string;
goal: string;
createdAt: number;
phases: OrchestratorPhase[];
metadata: {
totalTasks: number;
estimatedComplexity: 'low' | 'medium' | 'high';
modelUsed: string;
planDurationMs: number;
};
}
export interface OrchestratorPhase {
id: string; // "phase-1", "phase-2"
name: string; // Human-readable name
description: string;
order: number;
status: PhaseStatus;
tasks: OrchestratorTask[];
verificationCriteria: string[];
testCommands: string[];
maxAttempts: number; // Default: 3
attempts: number; // Current attempt count
startedAt: number | null;
completedAt: number | null;
durationMs: number | null;
teamStrategy: TeamStrategy;
}
export type PhaseStatus =
| 'pending'
| 'executing'
| 'verifying'
| 'passed'
| 'failed'
| 'skipped';
export interface OrchestratorTask {
id: string; // "phase-1-task-1"
phaseId: string;
prompt: string; // Single-line prompt for Claude
status: 'pending' | 'running' | 'completed' | 'failed';
assignedSessionId: string | null;
queueTaskId: string | null; // Links to TaskQueue task
parallel: boolean; // Can run in parallel with sibling tasks
completionPhrase: string; // Unique phrase for completion detection
timeoutMs: number;
startedAt: number | null;
completedAt: number | null;
error: string | null;
retries: number;
}
// ═══════════════════════════════════════════════════════════════
// Team Strategy
// ═══════════════════════════════════════════════════════════════
export type TeamStrategy =
| { type: 'single' } // One session handles all
| { type: 'parallel'; maxSessions: number } // Multiple sessions
| { type: 'team'; config: TeamSetup } // Agent teams
export interface TeamSetup {
leadPrompt: string;
suggestedTeammates: string[]; // Role descriptions
maxTeammates: number;
}
// ═══════════════════════════════════════════════════════════════
// Verification
// ═══════════════════════════════════════════════════════════════
export interface VerificationResult {
passed: boolean;
checks: VerificationCheck[];
summary: string;
suggestions: string[]; // Recovery hints for replanning
}
export interface VerificationCheck {
type: 'test_command' | 'ai_review' | 'file_check';
description: string;
passed: boolean;
output?: string;
}
// ═══════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorConfig {
plannerModel: string; // Default: 'opus'
researchEnabled: boolean; // Default: true
autoApprove: boolean; // Default: false
maxPhaseRetries: number; // Default: 3
phaseTimeoutMs: number; // Default: 1800000 (30min)
enableTeamAgents: boolean; // Default: true
maxParallelSessions: number; // Default: 3
verificationMode: 'strict' | 'moderate' | 'lenient';
compactBetweenPhases: boolean; // Default: true
}
// ═══════════════════════════════════════════════════════════════
// Persistence (saved to ~/.codeman/state.json)
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorPersistState {
state: OrchestratorState;
plan: OrchestratorPlan | null;
currentPhaseIndex: number;
startedAt: number | null;
completedAt: number | null;
config: OrchestratorConfig;
stats: OrchestratorStats;
}
export interface OrchestratorStats {
phasesCompleted: number;
phasesFailed: number;
totalTasksCompleted: number;
totalTasksFailed: number;
totalDurationMs: number;
replanCount: number;
}
```
## New Files (Implementation Order)
### Step 1: `src/types/orchestrator.ts` — Type definitions
All interfaces above. No dependencies. ~120 lines.
### Step 2: `src/orchestrator-planner.ts` — Plan generation + phase grouping
~300 lines. Wraps existing PlanOrchestrator.
```typescript
/**
* @fileoverview Orchestrator plan generation — converts goals into phased plans.
*
* Uses PlanOrchestrator for AI plan generation, then groups PlanItems into
* sequential phases with team strategies and verification criteria.
*
* @module orchestrator-planner
*/
export class OrchestratorPlanner {
constructor(mux: TerminalMultiplexer, workingDir: string, config: OrchestratorConfig);
/** Generate plan from goal. Uses PlanOrchestrator internally. */
async generatePlan(goal: string, onProgress?: ProgressCallback): Promise<OrchestratorPlan>;
/** Cancel in-progress plan generation. */
async cancel(): Promise<void>;
// Internal
private groupIntoPhases(items: PlanItem[], goal: string): OrchestratorPhase[];
private assignTeamStrategies(phases: OrchestratorPhase[]): void;
private generateCompletionPhrases(plan: OrchestratorPlan): void;
}
```
**Phase grouping algorithm:**
1. Topological sort by `PlanItem.dependencies`
2. Group into dependency layers (Kahn's algorithm)
3. Within each layer, sub-group by `tddPhase` (setup → test → impl → verify → review)
4. Merge adjacent small phases (< 2 tasks) if they share the same tddPhase
5. Assign team strategies:
- 1-2 tasks → `{ type: 'single' }`
- 3+ independent tasks → `{ type: 'parallel', maxSessions: Math.min(taskCount, config.maxParallelSessions) }`
- 4+ tasks with high complexity → `{ type: 'team', config: { ... } }`
6. Generate unique completion phrases per task: `ORCH_P{phaseOrder}_T{taskIndex}`
### Step 3: `src/orchestrator-verifier.ts` — Phase verification
~200 lines.
```typescript
/**
* @fileoverview Orchestrator phase verification.
*
* Runs verification checks after each phase completes:
* test commands, AI review, and file existence checks.
*
* @module orchestrator-verifier
*/
export class OrchestratorVerifier {
constructor(config: OrchestratorConfig);
/** Run all verification checks for a completed phase. */
async verifyPhase(
phase: OrchestratorPhase,
session: Session,
mode: 'strict' | 'moderate' | 'lenient'
): Promise<VerificationResult>;
// Verification strategies
private async runTestCommands(commands: string[], session: Session): Promise<VerificationCheck[]>;
private async aiReview(phase: OrchestratorPhase, session: Session): Promise<VerificationCheck>;
}
```
**Verification modes:**
- `strict`: ALL test commands must pass AND AI review must approve
- `moderate`: Test commands must pass, AI review is advisory
- `lenient`: At least one test command passes, AI review skipped
**AI review prompt (sent as a task to the session):**
```
Review Phase "{phase.name}" completion. Check:
1. Expected functionality works
2. No obvious regressions
3. Code quality is acceptable
Criteria: {phase.verificationCriteria.join('\n')}
If ALL criteria are met, respond: ORCH_VERIFY_PASS
If ANY criteria fail, respond: ORCH_VERIFY_FAIL and explain what failed.
```
### Step 4: `src/orchestrator-loop.ts` — Core state machine
~500 lines. Main orchestrator engine.
```typescript
/**
* @fileoverview Orchestrator Loop — phased plan execution with team agents.
*
* State machine that generates plans from user goals, executes them
* phase-by-phase with verification gates, and adapts on failure.
*
* @module orchestrator-loop
*/
export interface OrchestratorLoopEvents {
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
planReady: (plan: OrchestratorPlan) => void;
phaseStarted: (phase: OrchestratorPhase) => void;
phaseCompleted: (phase: OrchestratorPhase) => void;
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
taskCompleted: (task: OrchestratorTask) => void;
taskFailed: (task: OrchestratorTask, error: string) => void;
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
completed: (stats: OrchestratorStats) => void;
error: (error: Error) => void;
}
export class OrchestratorLoop extends EventEmitter {
private state: OrchestratorState = 'idle';
private plan: OrchestratorPlan | null = null;
private currentPhaseIndex = 0;
private config: OrchestratorConfig;
private planner: OrchestratorPlanner;
private verifier: OrchestratorVerifier;
private sessionManager: SessionManager;
private taskQueue: TaskQueue;
private store: StateStore;
private stats: OrchestratorStats;
private cleanup: CleanupManager;
private pausedState: OrchestratorState | null = null; // State before pause
// ── Lifecycle ──────────────────────────────────────────────
constructor(mux: TerminalMultiplexer, workingDir: string, config?: Partial<OrchestratorConfig>);
/** Start orchestration with a goal. Transitions: idle → planning */
async start(goal: string): Promise<void>;
/** Approve the generated plan. Transitions: approval → executing */
async approve(): Promise<void>;
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
async reject(feedback: string): Promise<void>;
/** Pause execution. Saves current state. */
pause(): void;
/** Resume from pause. */
resume(): void;
/** Stop everything and clean up. → idle */
async stop(): Promise<void>;
/** Skip current phase. → executing (next phase) or completed */
async skipPhase(phaseId: string): Promise<void>;
/** Retry a failed phase. → executing */
async retryPhase(phaseId: string): Promise<void>;
// ── Getters ────────────────────────────────────────────────
getState(): OrchestratorState;
getPlan(): OrchestratorPlan | null;
getCurrentPhase(): OrchestratorPhase | null;
getStats(): OrchestratorStats;
getStatus(): OrchestratorPersistState;
// ── Internal: Phase Execution ──────────────────────────────
private async executeCurrentPhase(): Promise<void>;
private async executePhase(phase: OrchestratorPhase): Promise<void>;
private async assignPhaseTasks(phase: OrchestratorPhase): Promise<void>;
private handleTaskCompleted(taskId: string): void;
private handleTaskFailed(taskId: string, error: string): void;
private async onPhaseTasksComplete(phase: OrchestratorPhase): Promise<void>;
// ── Internal: Verification ─────────────────────────────────
private async verifyCurrentPhase(): Promise<void>;
private async handleVerificationResult(phase: OrchestratorPhase, result: VerificationResult): Promise<void>;
// ── Internal: Replanning ───────────────────────────────────
private async replanPhase(phase: OrchestratorPhase, failures: string[]): Promise<void>;
// ── Internal: State Machine ────────────────────────────────
private setState(newState: OrchestratorState): void;
private advanceToNextPhase(): Promise<void>;
private persist(): void;
private restore(): void;
}
```
**Key execution flow in `executePhase()`:**
1. Mark phase as `executing`, emit `phaseStarted`
2. For each task in phase:
- Create a `CreateTaskOptions` from `OrchestratorTask`
- Add to `TaskQueue` with proper dependencies + completion phrase
- Store the TaskQueue task ID in `OrchestratorTask.queueTaskId`
3. Poll task completion (listen to TaskQueue events)
4. When all tasks complete → call `onPhaseTasksComplete()`
5. `onPhaseTasksComplete()` triggers verification
**How tasks get assigned to sessions:**
The OrchestratorLoop does NOT manage session assignment directly. It adds tasks to the existing TaskQueue and starts a mini poll loop that assigns pending tasks to idle sessions — the same pattern as RalphLoop's `assignTasks()`. This reuses existing session management.
**Team agent flow:**
For phases with `teamStrategy.type === 'team'`:
- Start a single session with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- Instead of adding individual tasks to TaskQueue, send ONE comprehensive prompt to the lead
- The prompt instructs the lead to create teammates and delegate
- Monitor via TeamWatcher for team task completion + hook events
- Phase completion is detected via the lead's completion phrase
### Step 5: `src/web/routes/orchestrator-routes.ts` — API endpoints
~300 lines.
```
POST /api/orchestrator/start — { goal, config? } → start planning
POST /api/orchestrator/approve — approve generated plan
POST /api/orchestrator/reject — { feedback } → reject + replan
POST /api/orchestrator/pause — pause execution
POST /api/orchestrator/resume — resume execution
POST /api/orchestrator/stop — stop orchestration
GET /api/orchestrator/status — full state + plan + stats
GET /api/orchestrator/plan — plan details only
POST /api/orchestrator/phase/:id/skip — skip a phase
POST /api/orchestrator/phase/:id/retry — retry a failed phase
```
Port dependency: `SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort`
The route module receives the OrchestratorLoop instance via the InfraPort (added to `createRouteContext()`).
### Step 6: SSE Events — `src/web/sse-events.ts` additions
```typescript
// ─── Orchestrator ────────────────────────────────────────────────────────────
/** Orchestrator state machine transitioned. */
export const OrchestratorStateChanged = 'orchestrator:stateChanged' as const;
/** Orchestrator plan generated and ready for approval. */
export const OrchestratorPlanReady = 'orchestrator:planReady' as const;
/** Orchestrator phase started executing. */
export const OrchestratorPhaseStarted = 'orchestrator:phaseStarted' as const;
/** Orchestrator phase completed successfully. */
export const OrchestratorPhaseCompleted = 'orchestrator:phaseCompleted' as const;
/** Orchestrator phase failed. */
export const OrchestratorPhaseFailed = 'orchestrator:phaseFailed' as const;
/** Orchestrator verification result for a phase. */
export const OrchestratorVerification = 'orchestrator:verification' as const;
/** Orchestrator task assigned to session. */
export const OrchestratorTaskAssigned = 'orchestrator:taskAssigned' as const;
/** Orchestrator task completed. */
export const OrchestratorTaskCompleted = 'orchestrator:taskCompleted' as const;
/** Orchestrator task failed. */
export const OrchestratorTaskFailed = 'orchestrator:taskFailed' as const;
/** All phases completed successfully. */
export const OrchestratorCompleted = 'orchestrator:completed' as const;
/** Orchestrator error. */
export const OrchestratorError = 'orchestrator:error' as const;
```
11 new events. Add to `SseEvent` namespace object + mirror in `constants.js`.
### Step 7: State persistence — `src/state-store.ts` additions
Add to `AppState`:
```typescript
orchestrator?: OrchestratorPersistState;
```
Add methods:
```typescript
getOrchestratorState(): OrchestratorPersistState | null;
setOrchestratorState(state: Partial<OrchestratorPersistState>): void;
clearOrchestratorState(): void;
```
### Step 8: Server integration — `src/web/server.ts` modifications
1. Import `OrchestratorLoop` and `registerOrchestratorRoutes`
2. Add `private orchestratorLoop: OrchestratorLoop` field
3. Initialize in constructor (lazy — created on first start, not at boot)
4. Add to `createRouteContext()` InfraPort: `orchestratorLoop: this.orchestratorLoop`
5. Wire up OrchestratorLoop events → SSE broadcasts
6. Register routes: `registerOrchestratorRoutes(this.app, ctx)`
7. Clean up in `stop()`
### Step 9: `src/web/public/orchestrator-ui.js` — Frontend panel
~500 lines. New frontend module.
**Load order**: After `panels-ui.js` (11), before `ralph-wizard.js` (13). So load order = 11.5.
**UI elements:**
- Goal input form (text area + config toggles)
- Plan approval view (phase list, task details, approve/reject buttons)
- Execution dashboard (progress bar, phase cards, task status indicators)
- Agent activity panel (session count, team status)
- Controls (pause, resume, stop, skip phase, retry phase)
**SSE listeners:**
- All 11 orchestrator events → update UI state
- Reuses existing session/respawn/team event handlers for agent monitoring
### Step 10: `src/prompts/orchestrator.ts` — Prompt templates
~200 lines.
Templates for:
- Phase execution prompt (tells Claude what to do in this phase)
- Team lead delegation prompt (instructs lead to create and coordinate teammates)
- Verification prompt (asks Claude to verify phase output)
- Replan prompt (gives failure context, asks for recovery steps)
### Step 11: Constants, schemas, route barrel updates
- `src/web/public/constants.js` — Add 11 SSE event mirrors
- `src/web/schemas.ts` — Add Zod schemas for orchestrator API input validation
- `src/web/routes/index.ts` — Export `registerOrchestratorRoutes`
- `src/web/ports/infra-port.ts` — Add `orchestratorLoop` to InfraPort
- `src/types/index.ts` — Export orchestrator types
## Existing File Modifications Summary
| File | Change | Lines |
|------|--------|-------|
| `src/types/index.ts` | Add orchestrator barrel export | +1 |
| `src/web/sse-events.ts` | Add 11 orchestrator events + SseEvent entries | +30 |
| `src/web/public/constants.js` | Mirror 11 SSE events | +15 |
| `src/web/routes/index.ts` | Export registerOrchestratorRoutes | +1 |
| `src/web/ports/infra-port.ts` | Add orchestratorLoop to InfraPort | +3 |
| `src/web/server.ts` | Initialize OrchestratorLoop, wire events, register routes | +40 |
| `src/web/schemas.ts` | Add orchestrator Zod schemas | +20 |
| `src/state-store.ts` | Add orchestrator state persistence | +20 |
| `src/web/public/app.js` | Add orchestrator SSE listeners + panel toggle | +30 |
| `src/web/public/index.html` | Add orchestrator-ui.js script tag | +1 |
**Total new code**: ~2,300 lines across 6 new files
**Total modifications**: ~160 lines across 10 existing files
## Implementation Execution Order
This is the actual build order — each step is a commit checkpoint:
1. **Types** — `src/types/orchestrator.ts` + barrel export. Zero risk, pure types.
2. **SSE events** — Add all 11 events to both `sse-events.ts` and `constants.js`. Wire in SseEvent namespace.
3. **State persistence** — Add orchestrator state to StateStore. Small, isolated change.
4. **Schemas** — Add Zod validation schemas for API input.
5. **Planner** — `src/orchestrator-planner.ts`. Can test in isolation.
6. **Verifier** — `src/orchestrator-verifier.ts`. Can test in isolation.
7. **Core loop** — `src/orchestrator-loop.ts`. The big one. Depends on planner + verifier.
8. **Prompts** — `src/prompts/orchestrator.ts`. Templates used by core loop.
9. **Port + routes** — `src/web/ports/infra-port.ts` update + `src/web/routes/orchestrator-routes.ts`.
10. **Server integration** — Wire OrchestratorLoop into WebServer. Routes become live.
11. **Frontend** — `src/web/public/orchestrator-ui.js` + app.js listeners + index.html script tag.
12. **Tests** — `test/orchestrator-*.test.ts`.
13. **Typecheck + lint** — Fix all issues, ensure CI passes.
## Edge Cases & Error Handling
- **Session limit reached**: Queue tasks and wait for sessions to free up (existing SessionManager handles this)
- **All sessions crash during phase**: Mark phase as failed, attempt replan
- **Verification flaky**: `moderate` mode allows test retries; `lenient` skips AI review
- **Plan too large**: Cap at 10 phases, 50 total tasks. Warn user.
- **Context overflow**: Auto-compact between phases. Respawn if needed (orchestrator state is external).
- **User pauses mid-phase**: Pause task assignment, don't cancel running tasks. Resume picks up where it left off.
- **Network/API errors during planning**: Retry plan generation up to 2 times, then fail with clear message.
- **Orchestrator vs Ralph conflict**: Mutually exclusive. Starting orchestrator stops Ralph if running. Starting Ralph stops orchestrator.
## Testing Strategy
- **Unit tests**: `test/orchestrator-planner.test.ts` — phase grouping algorithm, team strategy assignment
- **Unit tests**: `test/orchestrator-verifier.test.ts` — verification logic with mocked sessions
- **Integration tests**: `test/orchestrator-loop.test.ts` — state machine transitions, task lifecycle
- **Route tests**: `test/routes/orchestrator-routes.test.ts` — API validation, status responses
All tests use `MockSession` pattern from existing test infrastructure. No real tmux needed.
+157
View File
@@ -0,0 +1,157 @@
# Orchestrator Loop — Research Findings
> Research doc for the new "Orchestrator Loop" feature. Not for GitHub.
## What We're Building
A new autonomous loop variant — **Orchestrator Loop** — that takes high-level user tasks, decomposes them into a detailed plan using team agents, and executes the plan step-by-step with quality gates. Unlike Ralph Loop (which executes a flat task queue), the Orchestrator coordinates **planning, delegation, and verification** as a continuous cycle.
**Core idea**: User inputs a goal → Orchestrator creates a detailed plan → spins up team agents for parallel execution → validates each step → adapts the plan based on results → delivers polished output.
## Existing Infrastructure Analysis
### What We Can Reuse
#### 1. Ralph Loop (`src/ralph-loop.ts`)
- **Pattern**: Poll loop with `start() → tick() → stop()` lifecycle
- **Reusable**: Event-driven task assignment, session completion handling, timeout management
- **Limitation**: Flat task queue — no concept of phases, dependencies between task groups, or adaptive replanning
- **Key insight**: `assignTaskToSession()` uses `session.sendInput(task.prompt)` — simple prompt injection into PTY
#### 2. Task Queue (`src/task-queue.ts`) + Task (`src/task.ts`)
- **Already has**: Priority ordering, dependency tracking between tasks, completion phrase detection
- **Limitation**: No task *groups* or *phases*. Dependencies are task-to-task, not phase-to-phase
- **Key insight**: Tasks support `completionPhrase` — a string the task watches for in output. This is how Ralph knows a task is done
#### 3. Plan Orchestrator (`src/plan-orchestrator.ts`)
- **Already has**: 2-agent plan generation (Research Agent → Planner Agent), TDD-aware plan items with P0/P1/P2 priorities
- **Output**: `PlanItem[]` with dependencies, verification criteria, TDD phases, complexity ratings
- **Limitation**: Plan generation only — no execution. Plans are generated then sit in state/UI for human review
- **Key insight**: Uses `Session` directly to run Claude subagent instances for research and planning. Returns structured JSON
#### 4. Team Agents (`src/team-watcher.ts`, `~/.claude/teams/`)
- **Already has**: Team creation, member tracking, filesystem inbox messaging, task management via `~/.claude/tasks/{team-name}/`
- **Limitation**: Codeman can only *observe* teams (TeamWatcher is read-only polling), not *create* or *orchestrate* them
- **Key insight**: Teams are a Claude Code feature. Codeman monitors them but doesn't control them. We can't programmatically create teammates — Claude Code does that when you use `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
#### 5. Respawn Controller (`src/respawn-controller.ts`)
- **Already has**: Preset-based automation (ralph-todo, overnight-autonomous), circuit breaker, health scoring
- **Key insight**: The `ralph-todo` preset (8s idle, 480min max) is designed for autonomous task execution. We'd need a new preset or make Orchestrator Loop set its own timing
#### 6. Session Auto-Ops (`src/session-auto-ops.ts`)
- **Already has**: Auto-compact at token thresholds, auto-clear for context management
- **Key insight**: Critical for long Orchestrator runs — prevents context overflow during multi-step execution
#### 7. Hooks (`src/hooks-config.ts`)
- **Already has**: `idle_prompt`, `stop`, `teammate_idle`, `task_completed` hook events
- **Key insight**: Hooks fire POST to `/api/hook-event` — this is how Codeman knows when Claude is idle, stopped, or completed a task. The Orchestrator Loop can listen to these same events
### What We Need to Build New
1. **Plan → Task decomposition**: Convert PlanOrchestrator output (PlanItem[]) into executable task groups with phase ordering
2. **Multi-phase execution engine**: Execute plan phases sequentially, tasks within phases in parallel
3. **Verification gates**: After each phase, run verification (test commands, AI review) before proceeding
4. **Adaptive replanning**: When a task fails or verification fails, generate a recovery plan
5. **Team agent orchestration**: Leverage Claude Code's agent teams for parallel execution within phases
6. **Progress tracking & UI**: Real-time dashboard showing plan progress, phase status, agent activity
## How Teams Actually Work (Important Constraint)
After deep research, here's the reality of agent teams:
```
User starts session with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
→ Claude Code creates a team-lead
→ Team-lead spawns teammates (in-process threads)
→ Teammates appear as subagents (detected by SubagentWatcher)
→ Communication via ~/.claude/teams/{name}/inboxes/{member}.json
→ Tasks tracked in ~/.claude/tasks/{team-name}/{N}.json
```
**Codeman cannot programmatically create team members.** This is a Claude Code internal feature. However, Codeman CAN:
- Start a session that has teams enabled
- Send a prompt to the lead that instructs it to use agent teams
- Monitor team activity via TeamWatcher
- React to teammate_idle and task_completed hook events
- Read team task status from the filesystem
**This means**: The Orchestrator Loop orchestrates at the *session prompt* level, not the *team member* level. We tell the lead what to do, and the lead decides how to use its team.
## Architecture Decision: Prompt-Level Orchestration
Given the team constraint, the Orchestrator Loop works by:
1. **Planning phase**: Use PlanOrchestrator to generate a detailed plan from user input
2. **Execution phase**: Feed plan steps as prompts to sessions, one phase at a time
3. **Verification phase**: After each phase, run verification prompts and check results
4. **Adaptation phase**: If verification fails, generate recovery prompts
The "team agents" aspect works by:
- Starting sessions with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- Crafting prompts that *instruct the lead to delegate* to teammates
- Monitoring team activity to track parallel progress
- The lead agent is smart enough to decompose work across its team
## Key Technical Findings
### Session Input Mechanics
```typescript
// From session.ts - how we send prompts
await session.sendInput(task.prompt); // Uses writeViaMux() internally
// writeViaMux() does: tmux send-keys -l "prompt text" + tmux send-keys Enter
// CRITICAL: Single-line only! Multi-line breaks Ink rendering
```
### Completion Detection Chain
```
PTY output → RalphTracker.processData() → completion phrase fuzzy match
→ CompletionConfidence scoring (multi-signal: promise tag + todos + exit signal)
→ If confident → emit 'completionDetected'
→ RalphLoop listens → marks task complete → assigns next
```
### How Plan Items Map to Tasks
```typescript
// PlanItem has:
interface PlanItem {
id: string; // "P0-001"
content: string; // "Implement error handling for API endpoints"
priority: 'P0' | 'P1' | 'P2';
dependencies: string[]; // ["P0-000"] — other PlanItem IDs
verificationCriteria: string;
testCommand: string;
tddPhase: 'setup' | 'test' | 'impl' | 'verify' | 'review';
complexity: 'low' | 'medium' | 'high';
}
// Task has:
interface CreateTaskOptions {
prompt: string;
priority: number;
dependencies: string[]; // Task IDs
completionPhrase: string;
timeoutMs: number;
}
// Natural mapping: PlanItem.content → Task.prompt
// PlanItem.dependencies → Task.dependencies
// PlanItem.priority → Task.priority (P0=100, P1=50, P2=10)
// PlanItem.verificationCriteria → verification task prompt
```
### Context Management for Long Runs
- Auto-compact at ~110k tokens (configurable)
- Auto-clear at ~140k tokens (configurable)
- Respawn cycling: kill + restart session to reset context entirely
- For Orchestrator: we want compact between phases, respawn between major milestones
## Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| Context overflow during complex phases | High | Auto-compact between tasks, respawn between phases |
| Team agents not predictable | Medium | Orchestrate at session level, let Claude decide team delegation |
| Plan too ambitious → infinite loop | High | Phase budgets (max attempts per phase), circuit breaker |
| Verification too strict → blocks progress | Medium | Configurable strictness, human override via UI |
| Single-line prompt limit | Medium | Use CLAUDE.md file for complex instructions, prompt references file |
| Long planning phase delays execution | Low | Show plan for approval before execution |
+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) |
+74
View File
@@ -0,0 +1,74 @@
# Codeman Performance Optimization Plan
## Current State
The backend is **already production-grade** — SSE broadcasting, state persistence, terminal batching, buffer management, and memory patterns are all well-optimized. The biggest gains are on the **frontend delivery** side.
## Implemented Optimizations
### 1. V8 Compile Cache (10-20% faster cold start)
**Files:** `scripts/codeman-web.service`, `package.json`
Node.js re-parses and compiles all JS on every cold start. `NODE_COMPILE_CACHE` caches V8 compiled bytecode to disk, reusing it on subsequent starts.
- Added `Environment=NODE_COMPILE_CACHE=/home/arkon/.codeman/compile-cache` to systemd service
- Added to `npm start` script for non-systemd usage
- Zero code changes, immediate win on every restart
### 2. WebGL Addon Lazy-Loading (244KB saved on mobile, non-blocking on desktop)
**Files:** `src/web/public/index.html`, `src/web/public/app.js`
`xterm-addon-webgl.min.js` (244KB) was loaded eagerly for all users via `<script defer>`, but only used on desktop with WebGL2 support.
- Removed `<script defer>` from `index.html`
- Added dynamic script loading in `app.js` — only downloads on desktop when WebGL is needed
- Mobile users never download the file at all (244KB saved)
- Desktop: loads in parallel with page rendering, addon initializes when ready
- Graceful fallback: canvas renderer used if WebGL unavailable or script fails
### 3. Preload Hints (~50-100ms faster perceived load)
**Files:** `src/web/public/index.html`
Browser discovers `<script defer>` tags only when the parser reaches them at the bottom of `<body>`. By then, the HTML parse has blocked for hundreds of lines.
- Added `<link rel="preload" as="script">` in `<head>` for `vendor/xterm.min.js`, `constants.js`, `app.js`
- Browser starts fetching critical scripts immediately during HTML parse (before reaching `<body>`)
- Zero runtime overhead — just hints for the browser's preload scanner
### 4. Batch Tmux Reconciliation (N subprocess calls → 1)
**Files:** `src/tmux-manager.ts`
`reconcileSessions()` previously called `tmux has-session` + `tmux display-message` per known session, plus `tmux list-sessions` for discovery, plus `tmux display-message` per discovered session. With 20 sessions: 41+ subprocess calls.
- Replaced with single `tmux list-panes -a -F '#{session_name}\t#{pane_pid}'` call
- Builds a Map from the result, then does O(1) lookups for both known and discovered sessions
- Also replaced inner O(n) `isKnown` scan with a Set lookup
- 20 sessions: 41 subprocess calls → 1, with faster lookups
### 5. Asset Hashing / Cache Busting (already implemented)
**Files:** `scripts/build.mjs` (pre-existing)
Content-hash cache busting was already implemented in the build script:
- All app JS/CSS files get content hashes (`app.abc123.js`)
- `index.html` rewritten to reference hashed filenames
- Pre-compressed with gzip + Brotli
- 1-year immutable cache works correctly — new deploys get new filenames
## Already Optimized (No Action Needed)
| Area | Why It's Fine |
|------|---------------|
| **SSE Broadcasting** | Single serialization per broadcast, preformatted frames, backpressure handling, session subscription filtering |
| **State Persistence** | 500ms debounce, incremental per-session JSON caching, async atomic writes, circuit breaker on failures |
| **Terminal Batching** | Adaptive intervals (16-50ms), per-session queues, immediate flush at 32KB, array-based accumulation |
| **Buffer Management** | BufferAccumulator (array-push, lazy join), auto-trim at 2MB/1MB, no string concatenation in hot paths |
| **ANSI Stripping** | Pre-compiled regex via factory functions, single-pass processing |
| **Static File Serving** | @fastify/static with 1-year cache, pre-compressed Brotli/gzip, no-cache for HTML |
| **Memory Management** | CleanupManager, LRUMap, StaleExpirationMap, bounded buffers, explicit listener cleanup |
| **Import Patterns** | Pure ESM, lazy web server import, no circular deps, no dynamic imports in hot paths |
| **Config Loading** | Small constant files, no I/O at import time, specific imports (no barrel) |
+28 -7
View File
@@ -9,6 +9,8 @@
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
# CODEMAN_BRANCH - Git branch to install (default: master)
set -euo pipefail
@@ -17,7 +19,8 @@ set -euo pipefail
# ============================================================================
INSTALL_DIR="${CODEMAN_INSTALL_DIR:-$HOME/.codeman/app}"
REPO_URL="https://github.com/Ark0N/Codeman.git"
REPO_URL="${CODEMAN_REPO_URL:-https://github.com/Ark0N/Codeman.git}"
BRANCH="${CODEMAN_BRANCH:-master}"
MIN_NODE_VERSION=18
TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}"
NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}"
@@ -1062,26 +1065,27 @@ main() {
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Existing installation found, updating..."
cd "$INSTALL_DIR"
git remote set-url origin "$REPO_URL" 2>/dev/null || true
# Check for local changes
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
warn "Local changes detected in $INSTALL_DIR"
if prompt_yes_no "Discard local changes and update?" "n"; then
git fetch --quiet origin
git reset --hard origin/master --quiet
git reset --hard "origin/$BRANCH" --quiet
else
info "Keeping existing installation, skipping update"
fi
else
git fetch --quiet origin
git reset --hard origin/master --quiet
git reset --hard "origin/$BRANCH" --quiet
fi
else
# Create parent directory
mkdir -p "$(dirname "$INSTALL_DIR")"
# Clone repository (shallow for speed)
git clone --quiet --depth 1 "$REPO_URL" "$INSTALL_DIR"
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
@@ -1277,13 +1281,23 @@ update() {
info "Updating Codeman..."
cd "$INSTALL_DIR"
git remote set-url origin "$REPO_URL" 2>/dev/null || true
git fetch --quiet origin
git reset --hard origin/master --quiet
git reset --hard "origin/$BRANCH" --quiet
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
npm run build --quiet 2>/dev/null || npm run build
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
echo -e " ${DIM}Restart codeman web to use the new version.${NC}"
# Auto-restart systemd service if it's running, otherwise tell the user
if systemctl --user is-active codeman-web.service &>/dev/null; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service restarted"
else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
fi
echo ""
}
@@ -1355,5 +1369,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
+140 -56
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.3.1",
"version": "0.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.3.1",
"version": "0.3.11",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -17,8 +17,11 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0",
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
@@ -27,10 +30,6 @@
"qrcode": "^1.5.4",
"uuid": "^10.0.0",
"web-push": "^3.6.7",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6"
},
"bin": {
@@ -47,6 +46,7 @@
"@types/react": "^19.2.14",
"@types/uuid": "^10.0.0",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
@@ -64,6 +64,10 @@
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -943,6 +947,53 @@
"glob": "^11.0.0"
}
},
"node_modules/@fastify/websocket": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.3",
"fastify-plugin": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@fastify/websocket/node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/@fastify/websocket/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"dev": true,
@@ -1432,6 +1483,7 @@
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
@@ -1861,6 +1913,7 @@
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
@@ -2114,6 +2167,16 @@
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"dev": true,
@@ -2599,6 +2662,33 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"dev": true,
@@ -2988,7 +3078,9 @@
}
},
"node_modules/basic-ftp": {
"version": "5.1.0",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4385,7 +4477,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fastify": {
"version": "5.7.4",
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
"funding": [
{
"type": "github",
@@ -4407,7 +4501,7 @@
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^10.1.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
@@ -4570,6 +4664,20 @@
"dev": true,
"license": "Unlicense"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
@@ -5610,7 +5718,9 @@
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.2",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
@@ -6132,6 +6242,21 @@
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"dev": true,
@@ -6594,14 +6719,6 @@
"version": "4.0.4",
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"dev": true,
@@ -6992,14 +7109,6 @@
"node": ">=10"
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"license": "ISC"
@@ -7370,14 +7479,15 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.16",
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
"schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1"
},
"engines": {
@@ -8457,7 +8567,6 @@
},
"node_modules/ws": {
"version": "8.19.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -8495,31 +8604,6 @@
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-unicode11": {
"version": "0.6.0",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-webgl": {
"version": "0.16.0",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-zerolag-input": {
"resolved": "packages/xterm-zerolag-input",
"link": true
+12 -8
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.3.5",
"version": "0.5.6",
"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",
@@ -11,7 +11,7 @@
"scripts": {
"postinstall": "node scripts/postinstall.js",
"build": "node scripts/build.mjs",
"start": "node dist/index.js",
"start": "NODE_COMPILE_CACHE=${HOME}/.codeman/compile-cache node dist/index.js",
"dev": "tsx src/index.ts web",
"web": "node dist/index.js web",
"clean": "rm -rf dist",
@@ -51,8 +51,11 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0",
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
@@ -61,10 +64,6 @@
"qrcode": "^1.5.4",
"uuid": "^10.0.0",
"web-push": "^3.6.7",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -78,6 +77,7 @@
"@types/react": "^19.2.14",
"@types/uuid": "^10.0.0",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
@@ -93,6 +93,10 @@
"typescript-eslint": "^8.0.0",
"vitest": "^4.0.18"
},
"optionalDependencies": {
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7"
},
"engines": {
"node": ">=18.0.0"
},
+70 -11
View File
@@ -8,13 +8,15 @@
* 2. Copy static assets (web/public, templates)
* 3. Build vendor xterm bundles
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
* 5. Compress with gzip + brotli
* 5. Content-hash cache busting (rename assets, rewrite index.html)
* 6. Compress with gzip + brotli
*/
import { execSync } from 'child_process';
import { appendFileSync } from 'fs';
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
import { createHash } from 'crypto';
import { fileURLToPath } from 'url';
import { join } from 'path';
import { join, extname, basename, dirname } from 'path';
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
@@ -27,17 +29,18 @@ function run(label, cmd) {
run('tsc', 'tsc');
run('chmod dist/index.js', 'chmod +x dist/index.js');
// 2. Copy static assets
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
run('clean public', 'rm -rf dist/web/public');
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
run('copy web assets', 'cp -r src/web/public dist/web/');
run('copy template', 'cp src/templates/case-template.md dist/templates/');
// 3. Vendor xterm bundles
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
// 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
@@ -56,11 +59,67 @@ appendFileSync(
);
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
run('minify settings-ui.js', 'npx esbuild dist/web/public/settings-ui.js --minify --outfile=dist/web/public/settings-ui.js --allow-overwrite');
run('minify panels-ui.js', 'npx esbuild dist/web/public/panels-ui.js --minify --outfile=dist/web/public/panels-ui.js --allow-overwrite');
run('minify session-ui.js', 'npx esbuild dist/web/public/session-ui.js --minify --outfile=dist/web/public/session-ui.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',
'input-cjk.js',
'app.js',
'terminal-ui.js',
'respawn-ui.js',
'ralph-panel.js',
'settings-ui.js',
'panels-ui.js',
'session-ui.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` +
+1
View File
@@ -11,6 +11,7 @@ RestartSec=5
KillMode=process
Environment=NODE_ENV=production
Environment=HOME=/home/arkon
Environment=NODE_COMPILE_CACHE=/home/arkon/.codeman/compile-cache
# Logging
StandardOutput=journal
+9 -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,19 @@ if (isGlobalInstall) {
// Minify xterm JS for dev vendor dir (npm packages don't ship .min.js)
try {
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'xterm-addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
} catch {
// Fallback: copy unminified
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
copyFileSync(join(fitDir, 'lib', 'xterm-addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
}
// WebGL addon: copy unminified (matches build script behavior)
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
copyFileSync(join(webglDir, 'lib', 'addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
try {
+6 -10
View File
@@ -28,8 +28,9 @@ 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';
import { getErrorMessage } from './types.js';
// ========== Security Validation ==========
@@ -293,7 +294,7 @@ export abstract class AiCheckerBase<
this.emit('checkCompleted', result);
return result;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.handleError(errorMsg);
const result = this.createErrorResult(errorMsg, Date.now() - this.checkStartTime);
this.emit('checkFailed', errorMsg);
@@ -412,9 +413,7 @@ export abstract class AiCheckerBase<
});
muxProcess.unref();
} catch (err) {
throw new Error(
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
);
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${getErrorMessage(err)}`);
}
// Poll the temp file for completion
@@ -534,10 +533,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 */
+104 -122
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);
}
@@ -470,113 +470,91 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
* Process a single pre-stripped line of terminal output.
*/
private processCleanLine(cleanLine: string): void {
// Check for tool start
if (this._handleToolStart(cleanLine)) return;
if (this._handleToolCompletion(cleanLine)) return;
if (this._handleTextCommand(cleanLine)) return;
this._handleLogFileMention(cleanLine);
}
private _handleToolStart(cleanLine: string): boolean {
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) {
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
if (!startMatch) return false;
// Check if this is a file-viewing command
if (this.isFileViewerCommand(command)) {
const filePaths = this.extractFilePaths(command);
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
return;
}
if (!this.isFileViewerCommand(command)) return true;
if (filePaths.length > 0) {
const tool: ActiveBashTool = {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
const filePaths = this.extractFilePaths(command);
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
if (oldest) {
this._activeTools.delete(oldest[0]);
}
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
if (filePaths.length > 0) {
const tool = this._createActiveTool(command, filePaths, 'running', timeout);
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [key, entry] of this._activeTools) {
if (entry.startedAt < oldestTime) {
oldestTime = entry.startedAt;
oldestKey = key;
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
}
}
return;
}
// Check for tool completion
if (TOOL_COMPLETION_PATTERN.test(cleanLine) && this._lastToolId) {
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
// Remove completed tool after a short delay to allow UI to show completion
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
2000,
{ description: 'auto-remove completed tool' }
);
}
this._lastToolId = null;
return;
}
// Fallback: Check for command suggestions in plain text (e.g., "tail -f /tmp/file.log")
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (textCmdMatch) {
const filePath = textCmdMatch[2];
// Create a suggestion tool (marked as 'suggestion' status)
const tool: ActiveBashTool = {
id: uuidv4(),
command: cleanLine.trim(),
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running', // Shows as clickable
sessionId: this._sessionId,
};
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) {
return;
if (oldestKey) {
this._activeTools.delete(oldestKey);
}
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
30000,
{ description: 'auto-remove suggestion tool' }
);
return;
}
// Last fallback: Check for log file paths mentioned anywhere in the line
return true;
}
private _handleToolCompletion(cleanLine: string): boolean {
if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId) return false;
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
}
this._lastToolId = null;
return true;
}
private _handleTextCommand(cleanLine: string): boolean {
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (!textCmdMatch) return false;
const filePath = textCmdMatch[2];
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) return true;
const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
return true;
}
private _handleLogFileMention(cleanLine: string): void {
LOG_FILE_MENTION_PATTERN.lastIndex = 0;
let logMatch;
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
@@ -588,33 +566,46 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Skip if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) continue;
const tool: ActiveBashTool = {
id: uuidv4(),
command: `View: ${filePath}`,
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove after 60 seconds
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
60000,
{ description: 'auto-remove log file tool' }
);
this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
}
}
private _createActiveTool(
command: string,
filePaths: string[],
status: ActiveBashTool['status'],
timeout?: string
): ActiveBashTool {
return {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status,
sessionId: this._sessionId,
};
}
private _scheduleAutoRemove(toolId: string, delayMs: number, description: string): void {
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(toolId);
this.scheduleUpdate();
},
delayMs,
{ description }
);
}
/**
* Check if a command is a file-viewing command worth tracking.
*/
@@ -668,15 +659,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;
+21 -10
View File
@@ -22,14 +22,16 @@
* Maximum terminal buffer size in characters.
* Contains raw terminal output with ANSI escape sequences.
* Reduced from 5MB to 2MB for better render performance.
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
*/
export const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
/**
* Size to trim terminal buffer to when max is exceeded.
* Keeps the most recent portion to preserve context.
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
*/
export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
// ============================================================================
// Text Output Buffer Limits
@@ -38,13 +40,15 @@ export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB
/**
* Maximum text output buffer size in characters.
* Contains ANSI-stripped text for search and analysis.
* Override: CODEMAN_MAX_TEXT_OUTPUT (bytes)
*/
export const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024; // 1MB
export const MAX_TEXT_OUTPUT_SIZE = parseInt(process.env.CODEMAN_MAX_TEXT_OUTPUT || '') || 1 * 1024 * 1024;
/**
* Size to trim text output buffer to when max is exceeded.
* Override: CODEMAN_TRIM_TEXT_TO (bytes)
*/
export const TRIM_TEXT_TO = 768 * 1024; // 768KB
export const TRIM_TEXT_TO = parseInt(process.env.CODEMAN_TRIM_TEXT_TO || '') || 768 * 1024;
// ============================================================================
// Message Buffer Limits
@@ -53,13 +57,9 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
/**
* Maximum number of Claude JSON messages to keep in memory per session.
* Older messages are discarded when limit is exceeded.
* Override: CODEMAN_MAX_MESSAGES (count)
*/
export const MAX_MESSAGES = 1000;
/**
* Number of messages to keep when trimming (80% of max).
*/
export const TRIM_MESSAGES_TO = 800;
export const MAX_MESSAGES = parseInt(process.env.CODEMAN_MAX_MESSAGES || '') || 1000;
// ============================================================================
// Line Buffer Limits
@@ -85,3 +85,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)
+13
View File
@@ -73,3 +73,16 @@ export const MAX_CONSECUTIVE_ERRORS = 5;
/** Error counter reset interval — forgives errors after quiet period (ms) */
export const ERROR_RESET_MS = 60_000;
// ============================================================================
// Common Cleanup Intervals
// ============================================================================
/** Standard 1-minute cleanup/check interval used by multiple subsystems (ms) */
export const CLEANUP_CHECK_INTERVAL_MS = 60_000;
/** Standard 1-hour max age for stale/completed data (ms) */
export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
-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;
+5 -6
View File
@@ -16,6 +16,8 @@ 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 { getErrorMessage } from './types.js';
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// ========== Configuration Constants ==========
@@ -39,7 +41,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 +131,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 ==========
@@ -171,10 +173,7 @@ export class FileStreamManager extends EventEmitter {
}
} catch (err) {
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
console.warn(
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
err instanceof Error ? err.message : String(err)
);
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, getErrorMessage(err));
return { success: false, error: 'File not found or not accessible' };
}
+28
View File
@@ -116,6 +116,34 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
}
/**
* Updates the `model` field in .claude/settings.local.json for the given case path.
* Pass a non-empty string to set, or empty/null to remove.
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
if (model) {
existing.model = model;
} else {
delete existing.model;
}
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
}
/**
* Writes hooks config to .claude/settings.local.json in the given case path.
* Merges with existing file content, only touching the `hooks` key.
+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;
}
/**
+993
View File
@@ -0,0 +1,993 @@
/**
* @fileoverview Orchestrator Loop — phased plan execution with team agents.
*
* State machine that generates plans from user goals, executes them
* phase-by-phase with verification gates, and adapts on failure.
*
* States: idle → planning → approval → executing → verifying → (replanning) → completed/failed
*
* Key exports:
* - `OrchestratorLoop` class — main engine, extends EventEmitter
* - `OrchestratorLoopEvents` interface — typed event map
*
* Lifecycle: `start(goal)` → plan → approve → execute phases → verify → complete
*
* @dependencies orchestrator-planner (plan generation), orchestrator-verifier (phase verification),
* session-manager (sessions), task-queue (task execution), state-store (persistence),
* prompts/orchestrator (prompt templates)
* @consumedby web/server (orchestrator routes, SSE)
* @emits stateChanged, planReady, phaseStarted, phaseCompleted, phaseFailed,
* taskAssigned, taskCompleted, taskFailed, verificationResult, completed, error
* @persistence Orchestrator state saved to `~/.codeman/state.json` (orchestrator key)
*
* @module orchestrator-loop
*/
import { EventEmitter } from 'node:events';
import { getSessionManager, SessionManager } from './session-manager.js';
import { getTaskQueue, TaskQueue } from './task-queue.js';
import { getStore, StateStore } from './state-store.js';
import { OrchestratorPlanner } from './orchestrator-planner.js';
import { OrchestratorVerifier } from './orchestrator-verifier.js';
import { PHASE_EXECUTION_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT, TEAM_LEAD_PROMPT } from './prompts/index.js';
import type { TerminalMultiplexer } from './mux-interface.js';
import type { CreateTaskOptions } from './task.js';
import {
type OrchestratorState,
type OrchestratorPlan,
type OrchestratorPhase,
type OrchestratorTask,
type OrchestratorConfig,
type OrchestratorStats,
type OrchestratorPersistState,
type VerificationResult,
DEFAULT_ORCHESTRATOR_CONFIG,
createInitialOrchestratorStats,
getErrorMessage,
} from './types.js';
// ═══════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════
/** Poll interval for checking task completion within a phase (2 seconds) */
const PHASE_POLL_INTERVAL_MS = 2000;
/** Delay between phase completion and verification (1 second) */
const POST_PHASE_DELAY_MS = 1000;
// ═══════════════════════════════════════════════════════════════
// Events
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorLoopEvents {
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
planProgress: (phase: string, detail: string) => void;
planReady: (plan: OrchestratorPlan) => void;
phaseStarted: (phase: OrchestratorPhase) => void;
phaseCompleted: (phase: OrchestratorPhase) => void;
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
taskCompleted: (task: OrchestratorTask) => void;
taskFailed: (task: OrchestratorTask, error: string) => void;
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
completed: (stats: OrchestratorStats) => void;
error: (error: Error) => void;
}
// ═══════════════════════════════════════════════════════════════
// OrchestratorLoop
// ═══════════════════════════════════════════════════════════════
export class OrchestratorLoop extends EventEmitter {
private _state: OrchestratorState = 'idle';
private plan: OrchestratorPlan | null = null;
private currentPhaseIndex = 0;
private config: OrchestratorConfig;
private stats: OrchestratorStats;
private startedAt: number | null = null;
private completedAt: number | null = null;
private workingDir: string;
private planner: OrchestratorPlanner;
private verifier: OrchestratorVerifier;
private sessionManager: SessionManager;
private taskQueue: TaskQueue;
private store: StateStore;
/** State before pause (to resume to correct state) */
private pausedState: OrchestratorState | null = null;
/** Phase poll timer for checking task completion */
private phasePollTimer: NodeJS.Timeout | null = null;
/** Phase-level timeout timer */
private phaseTimeoutTimer: NodeJS.Timeout | null = null;
/** Post-phase delay timer before verification */
private postPhaseTimer: NodeJS.Timeout | null = null;
/** Session completion listener (bound for cleanup) */
private sessionCompletionListener: ((sessionId: string, phrase: string) => void) | null = null;
/** Active sessions assigned to current phase */
private phaseSessionIds: Set<string> = new Set();
constructor(mux: TerminalMultiplexer, workingDir: string, config?: Partial<OrchestratorConfig>) {
super();
this.workingDir = workingDir;
this.config = { ...DEFAULT_ORCHESTRATOR_CONFIG, ...config };
this.stats = createInitialOrchestratorStats();
this.sessionManager = getSessionManager();
this.taskQueue = getTaskQueue();
this.store = getStore();
this.planner = new OrchestratorPlanner(mux, workingDir, this.config);
this.verifier = new OrchestratorVerifier(this.config);
// Restore state if crashed while running
this.restore();
}
// ═══════════════════════════════════════════════════════════════
// Public API — Lifecycle
// ═══════════════════════════════════════════════════════════════
/** Start orchestration with a goal. Transitions: idle → planning */
async start(goal: string): Promise<void> {
if (this._state !== 'idle' && this._state !== 'failed' && this._state !== 'completed') {
throw new Error(`Cannot start from state "${this._state}"`);
}
this.reset();
this.startedAt = Date.now();
this.setState('planning');
try {
const plan = await this.planner.generatePlan(goal, (phase, detail) => {
this.emit('planProgress', phase, detail);
});
if (this.currentState() !== 'planning') {
// Cancelled during planning
return;
}
this.plan = plan;
this.persist();
if (this.config.autoApprove) {
this.setState('executing');
await this.executeCurrentPhase();
} else {
this.setState('approval');
this.emit('planReady', plan);
}
} catch (err) {
this.handleError(err);
}
}
/** Approve the generated plan. Transitions: approval → executing */
async approve(): Promise<void> {
this.requireState('approval');
if (!this.plan) {
throw new Error('No plan to approve');
}
this.setState('executing');
await this.executeCurrentPhase();
}
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
async reject(feedback: string): Promise<void> {
this.requireState('approval');
if (!this.plan) {
throw new Error('No plan to reject');
}
const goal = this.plan.goal + '\n\nFeedback on previous plan: ' + feedback;
this.plan = null;
this.setState('planning');
try {
const plan = await this.planner.generatePlan(goal);
if ((this._state as OrchestratorState) !== 'planning') return;
this.plan = plan;
this.persist();
this.setState('approval');
this.emit('planReady', plan);
} catch (err) {
this.handleError(err);
}
}
/** Pause execution. Saves current state. */
pause(): void {
if (this._state === 'idle' || this._state === 'paused' || this._state === 'completed' || this._state === 'failed') {
return;
}
this.pausedState = this._state;
this.clearPhasePoll();
this.cleanupTaskHandlers();
this.setState('paused');
}
/** Resume from pause. */
async resume(): Promise<void> {
if (this._state !== 'paused' || !this.pausedState) {
throw new Error('Not paused');
}
const resumeTo = this.pausedState;
this.pausedState = null;
this.setState(resumeTo);
// Re-enter the appropriate phase of execution
if (resumeTo === 'executing') {
await this.executeCurrentPhase();
} else if (resumeTo === 'verifying') {
await this.verifyCurrentPhase();
}
}
/** Stop everything and clean up. */
async stop(): Promise<void> {
this.clearPhasePoll();
this.cleanupTaskHandlers();
await this.planner.cancel();
this.setState('idle');
this.store.clearOrchestratorState();
}
/** Skip a specific phase. */
async skipPhase(phaseId: string): Promise<void> {
if (!this.plan) return;
const phase = this.plan.phases.find((p) => p.id === phaseId);
if (!phase) throw new Error(`Phase "${phaseId}" not found`);
phase.status = 'skipped';
phase.completedAt = Date.now();
this.persist();
// If this is the current phase, advance
if (this.plan.phases[this.currentPhaseIndex]?.id === phaseId) {
await this.advanceToNextPhase();
}
}
/** Retry a failed phase. */
async retryPhase(phaseId: string): Promise<void> {
if (!this.plan) return;
if (this._state !== 'executing' && this._state !== 'failed') {
throw new Error(`Cannot retry from state "${this._state}"`);
}
const phaseIndex = this.plan.phases.findIndex((p) => p.id === phaseId);
if (phaseIndex === -1) throw new Error(`Phase "${phaseId}" not found`);
const phase = this.plan.phases[phaseIndex];
phase.status = 'pending';
phase.attempts = 0;
for (const task of phase.tasks) {
task.status = 'pending';
task.error = null;
task.assignedSessionId = null;
task.queueTaskId = null;
}
this.currentPhaseIndex = phaseIndex;
this.setState('executing');
await this.executeCurrentPhase();
}
// ═══════════════════════════════════════════════════════════════
// Public API — Getters
// ═══════════════════════════════════════════════════════════════
get state(): OrchestratorState {
return this._state;
}
getPlan(): OrchestratorPlan | null {
return this.plan;
}
getCurrentPhase(): OrchestratorPhase | null {
if (!this.plan) return null;
return this.plan.phases[this.currentPhaseIndex] ?? null;
}
getStats(): OrchestratorStats {
return { ...this.stats };
}
getStatus(): OrchestratorPersistState {
return {
state: this._state,
plan: this.plan,
currentPhaseIndex: this.currentPhaseIndex,
startedAt: this.startedAt,
completedAt: this.completedAt,
config: this.config,
stats: this.stats,
};
}
isRunning(): boolean {
return this._state !== 'idle' && this._state !== 'completed' && this._state !== 'failed';
}
// ═══════════════════════════════════════════════════════════════
// Internal — Phase Execution
// ═══════════════════════════════════════════════════════════════
private async executeCurrentPhase(): Promise<void> {
if (!this.plan || this._state !== 'executing') return;
const phase = this.plan.phases[this.currentPhaseIndex];
if (!phase) {
// All phases done
await this.handleCompletion();
return;
}
// Skip already completed/skipped phases
if (phase.status === 'passed' || phase.status === 'skipped') {
await this.advanceToNextPhase();
return;
}
phase.status = 'executing';
phase.startedAt = Date.now();
phase.attempts++;
this.persist();
this.emit('phaseStarted', phase);
try {
await this.assignPhaseTasks(phase);
this.startPhasePoll(phase);
} catch (err) {
this.handlePhaseError(phase, getErrorMessage(err));
}
}
private async assignPhaseTasks(phase: OrchestratorPhase): Promise<void> {
// For team strategy, send a single comprehensive prompt to a lead session
if (phase.teamStrategy.type === 'team') {
await this.assignTeamPhase(phase);
return;
}
// For single/parallel strategy, add individual tasks to TaskQueue
for (const task of phase.tasks) {
if (task.status !== 'pending') continue;
const prompt = this.buildTaskPrompt(task, phase);
const taskOptions: CreateTaskOptions = {
prompt,
workingDir: this.workingDir,
priority: 100 - phase.order, // Earlier phases get higher priority
completionPhrase: task.completionPhrase,
timeoutMs: Math.min(task.timeoutMs, this.config.phaseTimeoutMs),
};
const queueTask = this.taskQueue.addTask(taskOptions);
task.queueTaskId = queueTask.id;
task.status = 'running';
}
this.persist();
this.setupTaskHandlers();
// Manually assign tasks to idle sessions
await this.assignQueuedTasksToSessions();
}
private async assignTeamPhase(phase: OrchestratorPhase): Promise<void> {
const teamConfig = phase.teamStrategy.type === 'team' ? phase.teamStrategy.config : null;
if (!teamConfig) return;
// Find or use an idle session
const sessions = this.sessionManager.getIdleSessions();
if (sessions.length === 0) {
throw new Error('No idle sessions available for team phase execution');
}
const session = sessions[0];
this.phaseSessionIds.add(session.id);
// Mark all tasks as running under this session
for (const task of phase.tasks) {
task.status = 'running';
task.assignedSessionId = session.id;
}
// Build and send the team lead prompt
const prompt = TEAM_LEAD_PROMPT.replace('{PHASE_NAME}', phase.name)
.replace('{TASK_LIST}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
.replace('{TEAMMATE_HINTS}', teamConfig.suggestedTeammates.map((h, i) => `${i + 1}. ${h}`).join('\n'))
.replace('{COMPLETION_PHRASE}', `${phase.id.toUpperCase()}_COMPLETE`);
// Create a TaskQueue task for the entire phase
const queueTask = this.taskQueue.addTask({
prompt,
workingDir: this.workingDir,
priority: 100 - phase.order,
completionPhrase: `${phase.id.toUpperCase()}_COMPLETE`,
timeoutMs: this.config.phaseTimeoutMs,
});
// Link all phase tasks to this single queue task
for (const task of phase.tasks) {
task.queueTaskId = queueTask.id;
}
this.persist();
this.setupTaskHandlers();
// Assign the task to the session
try {
queueTask.assign(session.id);
session.assignTask(queueTask.id);
this.taskQueue.updateTask(queueTask);
await session.sendInput(prompt);
} catch (err) {
queueTask.fail(getErrorMessage(err));
this.taskQueue.updateTask(queueTask);
throw err;
}
}
private async assignQueuedTasksToSessions(): Promise<void> {
const idleSessions = this.sessionManager.getIdleSessions();
const maxSessions =
this.getCurrentPhase()?.teamStrategy.type === 'parallel'
? (this.getCurrentPhase()?.teamStrategy as { type: 'parallel'; maxSessions: number }).maxSessions
: 1;
const sessionsToUse = idleSessions.slice(0, maxSessions);
for (const session of sessionsToUse) {
const task = this.taskQueue.next();
if (!task) break;
try {
task.assign(session.id);
session.assignTask(task.id);
this.taskQueue.updateTask(task);
await session.sendInput(task.prompt);
this.phaseSessionIds.add(session.id);
// Find the orchestrator task linked to this queue task
const orchTask = this.findOrchestratorTaskByQueueId(task.id);
if (orchTask) {
orchTask.assignedSessionId = session.id;
orchTask.startedAt = Date.now();
this.emit('taskAssigned', orchTask, session.id);
}
} catch (err) {
task.fail(getErrorMessage(err));
session.clearTask();
this.taskQueue.updateTask(task);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Internal — Task Completion Tracking
// ═══════════════════════════════════════════════════════════════
private setupTaskHandlers(): void {
this.cleanupTaskHandlers();
this.sessionCompletionListener = (_sessionId: string, _phrase: string) => {
// Session completion — check if it's related to our phase tasks
this.checkPhaseCompletion();
};
this.sessionManager.on('sessionCompletion', this.sessionCompletionListener);
}
private cleanupTaskHandlers(): void {
if (this.sessionCompletionListener) {
this.sessionManager.off('sessionCompletion', this.sessionCompletionListener);
this.sessionCompletionListener = null;
}
}
private _finalizeTask(queueTaskId: string, status: 'completed' | 'failed', error?: string): OrchestratorTask | null {
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
if (!orchTask) return null;
orchTask.status = status;
if (status === 'completed') {
orchTask.completedAt = Date.now();
this.stats.totalTasksCompleted++;
} else {
orchTask.error = error ?? null;
this.stats.totalTasksFailed++;
}
this.persist();
return orchTask;
}
private handleTaskCompleted(queueTaskId: string): void {
const orchTask = this._finalizeTask(queueTaskId, 'completed');
if (!orchTask) return;
this.emit('taskCompleted', orchTask);
this.checkPhaseCompletion();
}
private handleTaskFailed(queueTaskId: string, error: string): void {
const orchTask = this._finalizeTask(queueTaskId, 'failed', error);
if (!orchTask) return;
this.emit('taskFailed', orchTask, error);
// Check if we should retry the task or fail the phase
if (orchTask.retries < 2) {
orchTask.retries++;
orchTask.status = 'pending';
orchTask.error = null;
orchTask.queueTaskId = null;
// Will be re-queued on next poll
} else {
this.checkPhaseCompletion();
}
}
private startPhasePoll(phase: OrchestratorPhase): void {
this.clearPhasePoll();
this.phasePollTimer = setInterval(() => {
if (this._state !== 'executing') {
this.clearPhasePoll();
return;
}
this.pollPhaseStatus(phase);
}, PHASE_POLL_INTERVAL_MS);
// Phase-level timeout — fail the phase if it exceeds the configured timeout
this.phaseTimeoutTimer = setTimeout(() => {
if (this._state === 'executing' && phase.status === 'executing') {
console.warn(`[Orchestrator] Phase "${phase.name}" timed out after ${this.config.phaseTimeoutMs}ms`);
this.handlePhaseError(phase, `Phase timed out after ${Math.round(this.config.phaseTimeoutMs / 60000)} minutes`);
}
}, this.config.phaseTimeoutMs);
}
private _clearTimer(
timerKey: 'phasePollTimer' | 'phaseTimeoutTimer' | 'postPhaseTimer',
clearFn: typeof clearInterval | typeof clearTimeout
): void {
if (this[timerKey]) {
clearFn(this[timerKey]);
this[timerKey] = null;
}
}
private clearPhasePoll(): void {
this._clearTimer('phasePollTimer', clearInterval);
this._clearTimer('phaseTimeoutTimer', clearTimeout);
this._clearTimer('postPhaseTimer', clearTimeout);
}
private pollPhaseStatus(phase: OrchestratorPhase): void {
// Check for queued tasks that need assignment
const pendingTasks = phase.tasks.filter((t) => t.status === 'pending' && !t.queueTaskId);
if (pendingTasks.length > 0) {
// Re-queue pending tasks
for (const task of pendingTasks) {
const prompt = this.buildTaskPrompt(task, phase);
const queueTask = this.taskQueue.addTask({
prompt,
workingDir: this.workingDir,
priority: 100 - phase.order,
completionPhrase: task.completionPhrase,
timeoutMs: Math.min(task.timeoutMs, this.config.phaseTimeoutMs),
});
task.queueTaskId = queueTask.id;
task.status = 'running';
}
this.assignQueuedTasksToSessions().catch(() => {}); // Best effort
}
// Check completion status of queue tasks
for (const task of phase.tasks) {
if (task.status === 'running' && task.queueTaskId) {
const queueTask = this.taskQueue.getTask(task.queueTaskId);
if (queueTask) {
if (queueTask.isCompleted()) {
this.handleTaskCompleted(task.queueTaskId);
} else if (queueTask.isFailed()) {
this.handleTaskFailed(task.queueTaskId, queueTask.error || 'Task failed');
}
}
}
}
this.checkPhaseCompletion();
}
private checkPhaseCompletion(): void {
if (this._state !== 'executing') return;
const phase = this.getCurrentPhase();
if (!phase) return;
const allDone = phase.tasks.every((t) => t.status === 'completed' || t.status === 'failed');
if (!allDone) return;
const anyFailed = phase.tasks.some((t) => t.status === 'failed');
this.clearPhasePoll();
if (anyFailed) {
// Phase has failed tasks
this.handlePhaseError(phase, 'One or more tasks failed');
} else {
// All tasks completed — run verification after brief delay
this.postPhaseTimer = setTimeout(() => {
this.postPhaseTimer = null;
this.verifyCurrentPhase().catch((err) => this.handleError(err));
}, POST_PHASE_DELAY_MS);
}
}
// ═══════════════════════════════════════════════════════════════
// Internal — Verification
// ═══════════════════════════════════════════════════════════════
private async verifyCurrentPhase(): Promise<void> {
if (!this.plan) return;
const phase = this.plan.phases[this.currentPhaseIndex];
if (!phase) return;
// Skip verification if no criteria defined
if (phase.verificationCriteria.length === 0 && phase.testCommands.length === 0) {
phase.status = 'passed';
phase.completedAt = Date.now();
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
this.stats.phasesCompleted++;
this.persist();
this.emit('phaseCompleted', phase);
await this.advanceToNextPhase();
return;
}
this.setState('verifying');
// Get a session for verification — wait briefly for sessions to become idle
let sessions = this.sessionManager.getIdleSessions();
if (sessions.length === 0) {
// Wait up to 10s for a session to become idle
await new Promise((resolve) => setTimeout(resolve, 10_000));
sessions = this.sessionManager.getIdleSessions();
}
if (sessions.length === 0) {
// Still no sessions — log warning and skip verification (don't silently pass)
console.warn('[Orchestrator] No idle sessions for verification — skipping (marking passed with warning)');
phase.status = 'passed';
phase.completedAt = Date.now();
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
this.stats.phasesCompleted++;
this.persist();
this.emit('phaseCompleted', phase);
this.setState('executing');
await this.advanceToNextPhase();
return;
}
try {
const result = await this.verifier.verifyPhase(phase, sessions[0]);
this.emit('verificationResult', phase, result);
if (result.passed) {
phase.status = 'passed';
phase.completedAt = Date.now();
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
this.stats.phasesCompleted++;
this.persist();
this.emit('phaseCompleted', phase);
this.setState('executing');
await this.advanceToNextPhase();
} else {
// Verification failed — attempt replan
await this.handleVerificationFailure(phase, result);
}
} catch (err) {
// Verification error — treat as pass (don't block on verification bugs)
console.warn('[Orchestrator] Verification error, treating as pass:', err);
phase.status = 'passed';
phase.completedAt = Date.now();
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
this.stats.phasesCompleted++;
this.persist();
this.emit('phaseCompleted', phase);
this.setState('executing');
await this.advanceToNextPhase();
}
}
private async handleVerificationFailure(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
if (phase.attempts >= phase.maxAttempts) {
// Max retries exceeded
phase.status = 'failed';
phase.completedAt = Date.now();
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
this.stats.phasesFailed++;
this.persist();
this.emit('phaseFailed', phase, `Verification failed after ${phase.attempts} attempts: ${result.summary}`);
this.setState('failed');
return;
}
// Replan and retry
this.stats.replanCount++;
this.setState('replanning');
try {
await this.replanPhase(phase, result);
// Reset task states for retry
for (const task of phase.tasks) {
task.status = 'pending';
task.error = null;
task.assignedSessionId = null;
task.queueTaskId = null;
task.completedAt = null;
task.startedAt = null;
}
phase.status = 'pending';
phase.startedAt = null;
this.persist();
this.setState('executing');
await this.executeCurrentPhase();
} catch (err) {
this.handleError(err);
}
}
private async replanPhase(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
const completionPhrase = phase.tasks[0]?.completionPhrase || `${phase.id.toUpperCase()}_FIXED`;
const prompt = REPLAN_PROMPT.replace('{PHASE_NAME}', phase.name)
.replace('{ATTEMPT_NUMBER}', String(phase.attempts))
.replace('{MAX_ATTEMPTS}', String(phase.maxAttempts))
.replace('{FAILURE_SUMMARY}', result.summary)
.replace('{SUGGESTIONS}', result.suggestions.join('\n'))
.replace('{ORIGINAL_TASKS}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
.replace('{COMPLETION_PHRASE}', completionPhrase);
// Create a tracked queue task for the replan (so completion is detected)
const queueTask = this.taskQueue.addTask({
prompt,
workingDir: this.workingDir,
priority: 100,
completionPhrase,
timeoutMs: this.config.phaseTimeoutMs,
});
// Link to first phase task for tracking
if (phase.tasks[0]) {
phase.tasks[0].queueTaskId = queueTask.id;
phase.tasks[0].status = 'running';
}
this.persist();
// Set up handlers so task completion is tracked
this.setupTaskHandlers();
// Assign to a session
const sessions = this.sessionManager.getIdleSessions();
if (sessions.length === 0) {
console.warn('[Orchestrator] No idle sessions for replan — task queued, will pick up on next poll');
// Start polling so the task gets assigned when a session becomes idle
this.startPhasePoll(phase);
return;
}
try {
queueTask.assign(sessions[0].id);
sessions[0].assignTask(queueTask.id);
this.taskQueue.updateTask(queueTask);
await sessions[0].sendInput(prompt);
} catch (err) {
queueTask.fail(getErrorMessage(err));
this.taskQueue.updateTask(queueTask);
}
}
// ═══════════════════════════════════════════════════════════════
// Internal — State Machine
// ═══════════════════════════════════════════════════════════════
/** Read current state (bypasses TypeScript narrowing from guards) */
private currentState(): OrchestratorState {
return this._state;
}
/** Assert state matches expected or throw */
private requireState(...expected: OrchestratorState[]): void {
if (!expected.includes(this._state)) {
throw new Error(`Expected state "${expected.join('|')}", got "${this._state}"`);
}
}
private setState(newState: OrchestratorState): void {
const prev = this._state;
if (prev === newState) return;
this._state = newState;
this.persist();
this.emit('stateChanged', newState, prev);
}
private async advanceToNextPhase(): Promise<void> {
this.currentPhaseIndex++;
this.phaseSessionIds.clear();
this.persist();
if (!this.plan || this.currentPhaseIndex >= this.plan.phases.length) {
await this.handleCompletion();
} else {
// Compact between phases if configured
if (this.config.compactBetweenPhases) {
const sessions = this.sessionManager.getIdleSessions();
for (const session of sessions) {
try {
await session.writeViaMux('/compact');
} catch {
// Best effort
}
}
// Brief delay for compact to take effect
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await this.executeCurrentPhase();
}
}
private async handleCompletion(): Promise<void> {
this.completedAt = Date.now();
this.stats.totalDurationMs = this.startedAt ? this.completedAt - this.startedAt : 0;
this.clearPhasePoll();
this.cleanupTaskHandlers();
this.setState('completed');
this.emit('completed', this.stats);
}
private handlePhaseError(phase: OrchestratorPhase, error: string): void {
if (phase.attempts >= phase.maxAttempts) {
phase.status = 'failed';
phase.completedAt = Date.now();
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
this.stats.phasesFailed++;
this.persist();
this.emit('phaseFailed', phase, error);
this.setState('failed');
} else {
// Retry the phase
for (const task of phase.tasks) {
if (task.status === 'failed') {
task.status = 'pending';
task.error = null;
task.queueTaskId = null;
task.assignedSessionId = null;
}
}
phase.status = 'pending';
this.persist();
this.executeCurrentPhase().catch((err) => this.handleError(err));
}
}
private handleError(err: unknown): void {
const error = err instanceof Error ? err : new Error(getErrorMessage(err));
console.error('[Orchestrator] Error:', error.message);
this.setState('failed');
this.emit('error', error);
}
// ═══════════════════════════════════════════════════════════════
// Internal — Persistence
// ═══════════════════════════════════════════════════════════════
private persist(): void {
this.store.setOrchestratorState(this.getStatus());
}
private restore(): void {
const saved = this.store.getOrchestratorState();
if (!saved) return;
// If we crashed while running, reset to failed
if (saved.state === 'executing' || saved.state === 'verifying' || saved.state === 'replanning') {
this._state = 'failed';
this.plan = saved.plan;
this.currentPhaseIndex = saved.currentPhaseIndex;
this.startedAt = saved.startedAt;
this.config = saved.config;
this.stats = saved.stats;
this.store.setOrchestratorState({ ...saved, state: 'failed' });
} else if (saved.state === 'planning' || saved.state === 'approval') {
// Planning/approval — reset to idle (plan is lost)
this.store.clearOrchestratorState();
} else if (saved.state === 'completed' || saved.state === 'failed') {
// Preserve completed/failed state for UI display
this._state = saved.state;
this.plan = saved.plan;
this.currentPhaseIndex = saved.currentPhaseIndex;
this.startedAt = saved.startedAt;
this.completedAt = saved.completedAt;
this.config = saved.config;
this.stats = saved.stats;
}
}
private reset(): void {
this._state = 'idle';
this.plan = null;
this.currentPhaseIndex = 0;
this.startedAt = null;
this.completedAt = null;
this.stats = createInitialOrchestratorStats();
this.pausedState = null;
this.phaseSessionIds.clear();
this.clearPhasePoll();
this.cleanupTaskHandlers();
}
// ═══════════════════════════════════════════════════════════════
// Internal — Helpers
// ═══════════════════════════════════════════════════════════════
private buildTaskPrompt(task: OrchestratorTask, phase: OrchestratorPhase): string {
if (phase.tasks.length === 1) {
// Single task — use simpler prompt
const completedPhases = this.getCompletedPhasesSummary();
return SINGLE_TASK_PROMPT.replace('{TASK}', task.prompt)
.replace('{GOAL}', this.plan?.goal || '')
.replace('{CONTEXT}', completedPhases ? `Previous phases completed: ${completedPhases}` : '')
.replace('{COMPLETION_PHRASE}', task.completionPhrase);
}
// Multi-task phase — use full prompt
return PHASE_EXECUTION_PROMPT.replace('{PHASE_NAME}', phase.name)
.replace('{GOAL}', this.plan?.goal || '')
.replace('{COMPLETED_PHASES}', this.getCompletedPhasesSummary() || 'None yet')
.replace('{TASK_LIST}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
.replace('{VERIFICATION_CRITERIA}', phase.verificationCriteria.join('\n') || 'No specific criteria')
.replace('{COMPLETION_PHRASE}', task.completionPhrase);
}
private getCompletedPhasesSummary(): string {
if (!this.plan) return '';
return this.plan.phases
.filter((p) => p.status === 'passed' || p.status === 'skipped')
.map((p) => `${p.name}: ${p.status}`)
.join(', ');
}
private findOrchestratorTaskByQueueId(queueTaskId: string): OrchestratorTask | null {
if (!this.plan) return null;
for (const phase of this.plan.phases) {
for (const task of phase.tasks) {
if (task.queueTaskId === queueTaskId) return task;
}
}
return null;
}
/** Clean up resources when the loop is being destroyed. */
destroy(): void {
this.clearPhasePoll();
this.cleanupTaskHandlers();
}
}
+412
View File
@@ -0,0 +1,412 @@
/**
* @fileoverview Orchestrator plan generation — converts goals into phased plans.
*
* Wraps PlanOrchestrator for AI-powered plan generation, then groups the
* resulting PlanItems into sequential phases with team strategies and
* verification criteria.
*
* Phase grouping algorithm:
* 1. Topological sort by dependencies (Kahn's algorithm)
* 2. Group into dependency layers
* 3. Sub-group by TDD phase within layers
* 4. Merge small adjacent phases
* 5. Assign team strategies based on parallelism potential
*
* Key exports:
* - `OrchestratorPlanner` class — plan generation + phase grouping
*
* @dependencies plan-orchestrator (AI plan generation), types (OrchestratorPlan, PlanItem)
* @consumedby orchestrator-loop
*
* @module orchestrator-planner
*/
import { v4 as uuidv4 } from 'uuid';
import { PlanOrchestrator, type DetailedPlanResult, type ProgressCallback } from './plan-orchestrator.js';
import type { TerminalMultiplexer } from './mux-interface.js';
import type {
PlanItem,
TddPhase,
OrchestratorPlan,
OrchestratorPhase,
OrchestratorTask,
OrchestratorConfig,
TeamStrategy,
PhaseStatus,
} from './types.js';
// ═══════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════
/** Maximum number of phases (prevents runaway plans) */
const MAX_PHASES = 10;
/** Maximum total tasks across all phases */
const MAX_TOTAL_TASKS = 50;
/** Default task timeout (10 minutes) */
const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000;
/** Minimum tasks in a phase before it gets merged with adjacent */
const MIN_PHASE_TASKS = 2;
/** TDD phase ordering for grouping */
const TDD_PHASE_ORDER: Record<TddPhase, number> = {
setup: 0,
test: 1,
impl: 2,
verify: 3,
review: 4,
};
// ═══════════════════════════════════════════════════════════════
// OrchestratorPlanner
// ═══════════════════════════════════════════════════════════════
export class OrchestratorPlanner {
private mux: TerminalMultiplexer;
private workingDir: string;
private config: OrchestratorConfig;
private orchestrator: PlanOrchestrator | null = null;
constructor(mux: TerminalMultiplexer, workingDir: string, config: OrchestratorConfig) {
this.mux = mux;
this.workingDir = workingDir;
this.config = config;
}
/**
* Generate a phased plan from a user goal.
*
* Uses PlanOrchestrator for AI plan generation, then groups results into phases.
*/
async generatePlan(goal: string, onProgress?: ProgressCallback): Promise<OrchestratorPlan> {
const startTime = Date.now();
// Create a PlanOrchestrator for this plan generation
this.orchestrator = new PlanOrchestrator(this.mux, this.workingDir, undefined, {
defaultModel: this.config.plannerModel,
});
try {
onProgress?.('planning', 'Generating detailed plan...');
const result: DetailedPlanResult = await this.orchestrator.generateDetailedPlan(goal, onProgress);
if (!result.success || !result.items || result.items.length === 0) {
throw new Error(result.error || 'Plan generation returned no items');
}
// Cap total tasks
const items = result.items.slice(0, MAX_TOTAL_TASKS);
onProgress?.('grouping', 'Organizing plan into phases...');
// Group items into phases
const phases = this.groupIntoPhases(items, goal);
// Assign team strategies
this.assignTeamStrategies(phases);
// Generate unique completion phrases
this.generateCompletionPhrases(phases);
const plan: OrchestratorPlan = {
id: uuidv4(),
goal,
createdAt: Date.now(),
phases,
metadata: {
totalTasks: phases.reduce((sum, p) => sum + p.tasks.length, 0),
estimatedComplexity: this.estimateComplexity(items),
modelUsed: this.config.plannerModel,
planDurationMs: Date.now() - startTime,
},
};
return plan;
} finally {
this.orchestrator = null;
}
}
/** Cancel in-progress plan generation. */
async cancel(): Promise<void> {
if (this.orchestrator) {
await this.orchestrator.cancel();
this.orchestrator = null;
}
}
// ═══════════════════════════════════════════════════════════════
// Phase Grouping
// ═══════════════════════════════════════════════════════════════
/**
* Group PlanItems into sequential phases.
*
* Algorithm:
* 1. Build dependency graph and assign IDs to items without them
* 2. Topological sort into dependency layers (Kahn's algorithm)
* 3. Sub-group within each layer by TDD phase
* 4. Merge small phases with their neighbors
*/
private groupIntoPhases(items: PlanItem[], _goal: string): OrchestratorPhase[] {
// Ensure all items have IDs
const indexedItems = items.map((item, i) => ({
...item,
id: item.id || `task-${i}`,
}));
// Build adjacency and in-degree for Kahn's algorithm
const idSet = new Set(indexedItems.map((item) => item.id!));
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>(); // id → items that depend on it
for (const item of indexedItems) {
inDegree.set(item.id!, 0);
dependents.set(item.id!, []);
}
for (const item of indexedItems) {
const deps = (item.dependencies || []).filter((d) => idSet.has(d));
inDegree.set(item.id!, deps.length);
for (const dep of deps) {
dependents.get(dep)!.push(item.id!);
}
}
// Kahn's algorithm — produce dependency layers
const layers: PlanItem[][] = [];
const remaining = new Set(indexedItems.map((item) => item.id!));
while (remaining.size > 0) {
// Find items with no remaining dependencies (in-degree 0)
const layer: PlanItem[] = [];
for (const id of remaining) {
if (inDegree.get(id)! === 0) {
layer.push(indexedItems.find((item) => item.id === id)!);
}
}
if (layer.length === 0) {
// Circular dependency — add all remaining items as a single layer
for (const id of remaining) {
layer.push(indexedItems.find((item) => item.id === id)!);
}
}
layers.push(layer);
// Remove this layer's items and update in-degrees
for (const item of layer) {
remaining.delete(item.id!);
for (const dep of dependents.get(item.id!) || []) {
if (remaining.has(dep)) {
inDegree.set(dep, Math.max(0, inDegree.get(dep)! - 1));
}
}
}
}
// Sub-group each layer by TDD phase
const rawPhases: PlanItem[][] = [];
for (const layer of layers) {
const byPhase = new Map<string, PlanItem[]>();
for (const item of layer) {
const phase = item.tddPhase || 'impl';
if (!byPhase.has(phase)) byPhase.set(phase, []);
byPhase.get(phase)!.push(item);
}
// Sort sub-groups by TDD phase order
const sorted = [...byPhase.entries()].sort(
([a], [b]) => (TDD_PHASE_ORDER[a as TddPhase] ?? 2) - (TDD_PHASE_ORDER[b as TddPhase] ?? 2)
);
for (const [, items] of sorted) {
rawPhases.push(items);
}
}
// Merge small phases with their previous neighbor
const mergedPhases: PlanItem[][] = [];
for (const phase of rawPhases) {
if (mergedPhases.length > 0 && phase.length < MIN_PHASE_TASKS) {
const prev = mergedPhases[mergedPhases.length - 1];
if (prev.length < MIN_PHASE_TASKS) {
// Merge with previous
prev.push(...phase);
continue;
}
}
mergedPhases.push([...phase]);
}
// Cap at MAX_PHASES by merging tail phases
while (mergedPhases.length > MAX_PHASES) {
const last = mergedPhases.pop()!;
mergedPhases[mergedPhases.length - 1].push(...last);
}
// Convert to OrchestratorPhase objects
return mergedPhases.map((phaseItems, index) => this.createPhase(phaseItems, index));
}
private createPhase(items: PlanItem[], order: number): OrchestratorPhase {
// Derive phase name from TDD phases and priorities
const tddPhases = [...new Set(items.map((i) => i.tddPhase).filter(Boolean))];
const name = this.generatePhaseName(items, tddPhases as TddPhase[], order);
const description = items.map((i) => i.content).join('; ');
const tasks: OrchestratorTask[] = items.map((item, i) => ({
id: `phase-${order + 1}-task-${i + 1}`,
phaseId: `phase-${order + 1}`,
prompt: item.content,
status: 'pending' as const,
assignedSessionId: null,
queueTaskId: null,
parallel: items.length > 1, // Tasks within a phase are parallel by default
completionPhrase: '', // Assigned later
timeoutMs: DEFAULT_TASK_TIMEOUT_MS,
startedAt: null,
completedAt: null,
error: null,
retries: 0,
}));
// Extract verification criteria and test commands from items
const verificationCriteria = items
.map((i) => i.verificationCriteria)
.filter((v): v is string => v != null && v.length > 0);
const testCommands = items.map((i) => i.testCommand).filter((t): t is string => t != null && t.length > 0);
return {
id: `phase-${order + 1}`,
name,
description,
order,
status: 'pending' as PhaseStatus,
tasks,
verificationCriteria,
testCommands,
maxAttempts: this.config.maxPhaseRetries,
attempts: 0,
startedAt: null,
completedAt: null,
durationMs: null,
teamStrategy: { type: 'single' }, // Assigned later
};
}
private generatePhaseName(items: PlanItem[], tddPhases: TddPhase[], order: number): string {
// Try to create a meaningful name based on content
const priorities = [...new Set(items.map((i) => i.priority).filter(Boolean))];
if (tddPhases.length === 1) {
const phaseNames: Record<TddPhase, string> = {
setup: 'Setup & Configuration',
test: 'Test Definition',
impl: 'Implementation',
verify: 'Verification',
review: 'Review & Polish',
};
return `Phase ${order + 1}: ${phaseNames[tddPhases[0]]}`;
}
if (priorities.includes('P0') && priorities.length === 1) {
return `Phase ${order + 1}: Critical Foundation`;
}
return `Phase ${order + 1}: ${items.length > 1 ? 'Parallel Tasks' : items[0].content.slice(0, 50)}`;
}
// ═══════════════════════════════════════════════════════════════
// Team Strategy Assignment
// ═══════════════════════════════════════════════════════════════
private assignTeamStrategies(phases: OrchestratorPhase[]): void {
for (const phase of phases) {
phase.teamStrategy = this.computeTeamStrategy(phase);
}
}
private computeTeamStrategy(phase: OrchestratorPhase): TeamStrategy {
const taskCount = phase.tasks.length;
const parallelTasks = phase.tasks.filter((t) => t.parallel).length;
// Single task or no parallel potential → single session
if (taskCount <= 2 || parallelTasks <= 1) {
return { type: 'single' };
}
// If team agents are disabled, use parallel sessions instead
if (!this.config.enableTeamAgents) {
return {
type: 'parallel',
maxSessions: Math.min(parallelTasks, this.config.maxParallelSessions),
};
}
// 4+ parallel tasks with team agents enabled → team mode
if (parallelTasks >= 4) {
return {
type: 'team',
config: {
leadPrompt: this.buildTeamLeadPrompt(phase),
suggestedTeammates: phase.tasks.slice(0, 4).map((t) => `Specialist for: ${t.prompt.slice(0, 80)}`),
maxTeammates: Math.min(parallelTasks, 4),
},
};
}
// 3 parallel tasks → parallel sessions
return {
type: 'parallel',
maxSessions: Math.min(parallelTasks, this.config.maxParallelSessions),
};
}
private buildTeamLeadPrompt(phase: OrchestratorPhase): string {
const taskList = phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n');
return [
`You are the team lead for "${phase.name}".`,
`Create teammates and delegate the following tasks for parallel execution:`,
'',
taskList,
'',
`Each teammate should focus on one task area.`,
`When all tasks are complete, verify the results and output: <promise>${phase.id.toUpperCase()}_COMPLETE</promise>`,
].join('\n');
}
// ═══════════════════════════════════════════════════════════════
// Completion Phrases
// ═══════════════════════════════════════════════════════════════
private generateCompletionPhrases(phases: OrchestratorPhase[]): void {
for (const phase of phases) {
for (const task of phase.tasks) {
// Generate a unique, deterministic completion phrase per task
task.completionPhrase = `ORCH_P${phase.order + 1}_T${phase.tasks.indexOf(task) + 1}`;
}
}
}
// ═══════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════
private estimateComplexity(items: PlanItem[]): 'low' | 'medium' | 'high' {
const total = items.length;
const highComplexity = items.filter((i) => i.complexity === 'high').length;
const p0Count = items.filter((i) => i.priority === 'P0').length;
if (total > 20 || highComplexity > 5 || p0Count > 8) return 'high';
if (total > 10 || highComplexity > 2 || p0Count > 4) return 'medium';
return 'low';
}
}
+298
View File
@@ -0,0 +1,298 @@
/**
* @fileoverview Orchestrator phase verification.
*
* Runs verification checks after each phase completes:
* - Test commands (shell commands via session)
* - AI review (ask Claude to evaluate phase results)
*
* Three verification modes:
* - strict: ALL test commands must pass AND AI review must approve
* - moderate: Test commands must pass, AI review is advisory
* - lenient: At least one test command passes, AI review skipped
*
* Key exports:
* - `OrchestratorVerifier` class — phase verification engine
*
* @dependencies types (OrchestratorPhase, VerificationResult, VerificationCheck, OrchestratorConfig)
* @consumedby orchestrator-loop
*
* @module orchestrator-verifier
*/
import type { Session } from './session.js';
import {
getErrorMessage,
type OrchestratorPhase,
type OrchestratorConfig,
type VerificationResult,
type VerificationCheck,
} from './types.js';
// ═══════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════
/** Timeout for individual test command execution (2 minutes) */
const TEST_COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
/** Timeout for AI review (3 minutes) */
const AI_REVIEW_TIMEOUT_MS = 3 * 60 * 1000;
/** Completion phrase for AI verification pass */
const VERIFY_PASS_PHRASE = 'ORCH_VERIFY_PASS';
/** Completion phrase for AI verification fail */
const VERIFY_FAIL_PHRASE = 'ORCH_VERIFY_FAIL';
// ═══════════════════════════════════════════════════════════════
// OrchestratorVerifier
// ═══════════════════════════════════════════════════════════════
export class OrchestratorVerifier {
private config: OrchestratorConfig;
constructor(config: OrchestratorConfig) {
this.config = config;
}
/**
* Run all verification checks for a completed phase.
*
* @param phase - The phase to verify
* @param session - Session to use for running commands/reviews
* @returns Verification result with pass/fail and suggestions
*/
async verifyPhase(phase: OrchestratorPhase, session: Session): Promise<VerificationResult> {
const checks: VerificationCheck[] = [];
const mode = this.config.verificationMode;
// Skip verification entirely in lenient mode with no test commands
if (mode === 'lenient' && phase.testCommands.length === 0 && phase.verificationCriteria.length === 0) {
return {
passed: true,
checks: [],
summary: 'Verification skipped (lenient mode, no checks defined)',
suggestions: [],
};
}
// Run test commands if any are defined
if (phase.testCommands.length > 0) {
const testChecks = await this.runTestCommands(phase.testCommands, session);
checks.push(...testChecks);
}
// Run AI review in strict and moderate modes
if (mode !== 'lenient' && phase.verificationCriteria.length > 0) {
const aiCheck = await this.aiReview(phase, session);
checks.push(aiCheck);
}
// Determine pass/fail based on mode
const passed = this.evaluateChecks(checks, mode);
// Generate suggestions for failed checks
const suggestions = this.generateSuggestions(checks, phase);
const passedCount = checks.filter((c) => c.passed).length;
const summary =
checks.length === 0 ? 'No verification checks defined' : `${passedCount}/${checks.length} checks passed`;
return { passed, checks, summary, suggestions };
}
// ═══════════════════════════════════════════════════════════════
// Test Command Execution
// ═══════════════════════════════════════════════════════════════
private async runTestCommands(commands: string[], session: Session): Promise<VerificationCheck[]> {
const checks: VerificationCheck[] = [];
for (const command of commands) {
try {
const check = await this.runSingleTestCommand(command, session);
checks.push(check);
} catch (err) {
checks.push({
type: 'test_command',
description: `Run: ${command}`,
passed: false,
output: getErrorMessage(err),
});
}
}
return checks;
}
private async runSingleTestCommand(command: string, session: Session): Promise<VerificationCheck> {
// Send the test command to the session and wait for completion
// We use a unique marker to detect when the command finishes
const marker = `ORCH_TEST_${Date.now()}`;
const wrappedCommand = `${command} && echo ${marker}_PASS || echo ${marker}_FAIL`;
const result = await this.sendAndWaitForMarker(session, wrappedCommand, marker, TEST_COMMAND_TIMEOUT_MS);
return {
type: 'test_command',
description: `Run: ${command}`,
passed: result.includes(`${marker}_PASS`),
output: result.slice(0, 2000), // Truncate output
};
}
// ═══════════════════════════════════════════════════════════════
// AI Review
// ═══════════════════════════════════════════════════════════════
private async aiReview(phase: OrchestratorPhase, session: Session): Promise<VerificationCheck> {
const prompt = this.buildVerificationPrompt(phase);
try {
const result = await this.sendAndWaitForMarker(
session,
prompt,
VERIFY_PASS_PHRASE,
AI_REVIEW_TIMEOUT_MS,
VERIFY_FAIL_PHRASE
);
const passed = result.includes(VERIFY_PASS_PHRASE);
return {
type: 'ai_review',
description: `AI review of "${phase.name}"`,
passed,
output: result.slice(0, 3000),
};
} catch (err) {
return {
type: 'ai_review',
description: `AI review of "${phase.name}"`,
passed: false,
output: `AI review timed out or failed: ${getErrorMessage(err)}`,
};
}
}
private buildVerificationPrompt(phase: OrchestratorPhase): string {
const criteria = phase.verificationCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
return [
`Review the work done in "${phase.name}". Check these criteria:`,
'',
criteria,
'',
`If ALL criteria are met, respond with: ${VERIFY_PASS_PHRASE}`,
`If ANY criteria fail, respond with: ${VERIFY_FAIL_PHRASE} and explain what failed.`,
].join('\n');
}
// ═══════════════════════════════════════════════════════════════
// Evaluation
// ═══════════════════════════════════════════════════════════════
private evaluateChecks(checks: VerificationCheck[], mode: OrchestratorConfig['verificationMode']): boolean {
if (checks.length === 0) return true;
const testChecks = checks.filter((c) => c.type === 'test_command');
const aiChecks = checks.filter((c) => c.type === 'ai_review');
switch (mode) {
case 'strict':
// ALL checks must pass
return checks.every((c) => c.passed);
case 'moderate':
// All test commands must pass; AI review is advisory
return testChecks.length === 0 || testChecks.every((c) => c.passed);
case 'lenient':
// At least one test passes (AI review skipped in lenient mode)
return testChecks.length === 0 || testChecks.some((c) => c.passed);
default:
return aiChecks.every((c) => c.passed) && testChecks.every((c) => c.passed);
}
}
private generateSuggestions(checks: VerificationCheck[], phase: OrchestratorPhase): string[] {
const suggestions: string[] = [];
const failedChecks = checks.filter((c) => !c.passed);
if (failedChecks.length === 0) return suggestions;
for (const check of failedChecks) {
if (check.type === 'test_command') {
suggestions.push(`Fix failing test: ${check.description}`);
} else if (check.type === 'ai_review' && check.output) {
// Extract failure reasons from AI review output
suggestions.push(`Address AI review feedback for "${phase.name}"`);
}
}
return suggestions;
}
// ═══════════════════════════════════════════════════════════════
// Session Communication
// ═══════════════════════════════════════════════════════════════
/**
* Send a prompt to a session and wait for a marker phrase in the output.
*
* @param session - Session to send to
* @param input - Prompt/command to send
* @param marker - Primary marker to watch for
* @param timeoutMs - Maximum wait time
* @param altMarker - Alternative marker (for pass/fail detection)
* @returns Captured output containing the marker
*/
private sendAndWaitForMarker(
session: Session,
input: string,
marker: string,
timeoutMs: number,
altMarker?: string
): Promise<string> {
return new Promise<string>((resolve, reject) => {
let output = '';
let resolved = false;
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
reject(new Error(`Timeout waiting for marker "${marker}" after ${timeoutMs}ms`));
}
}, timeoutMs);
const handler = (data: string) => {
if (resolved) return;
output += data;
if (output.includes(marker) || (altMarker && output.includes(altMarker))) {
resolved = true;
cleanup();
resolve(output);
}
};
const cleanup = () => {
clearTimeout(timer);
session.off('terminal', handler);
};
session.on('terminal', handler);
// Send the input
session.sendInput(input).catch((err) => {
if (!resolved) {
resolved = true;
cleanup();
reject(err);
}
});
});
}
}
+97 -91
View File
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import type { PlanItem } from './types.js';
import { getErrorMessage, type PlanItem } from './types.js';
// Re-export for backward compatibility
export type { PlanItem };
@@ -231,6 +231,49 @@ export class PlanOrchestrator {
return md;
}
private _extractJsonFromResponse(response: string): string | null {
let jsonMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
if (jsonMatch) {
jsonMatch = [jsonMatch[1]]; // Use captured group (inside code block)
} else {
jsonMatch = response.match(/\{[\s\S]*\}/);
}
return jsonMatch ? jsonMatch[0] : null;
}
private _emitAgentFailure(
onSubagent: SubagentCallback | undefined,
agentId: string,
agentType: 'research' | 'planner',
model: string,
error: string,
durationMs: number
): void {
onSubagent?.({
type: 'failed',
agentId,
agentType,
model,
status: 'failed',
error,
durationMs,
});
}
private _formatResearchSection(
parts: string[],
title: string,
items: unknown[],
formatter: (item: unknown) => string[]
): void {
if (items.length === 0) return;
parts.push(title);
for (const item of items.slice(0, 5)) {
parts.push(...formatter(item));
}
parts.push('');
}
async cancel(): Promise<void> {
this.cancelled = true;
// Stop all running sessions and await cleanup to prevent PTY process leaks
@@ -312,7 +355,7 @@ export class PlanOrchestrator {
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err),
};
}
}
@@ -322,32 +365,23 @@ export class PlanOrchestrator {
const parts: string[] = ['## Research Context\n'];
if (research.findings.externalResources.length > 0) {
parts.push('### External Resources');
for (const r of research.findings.externalResources.slice(0, 5)) {
parts.push(`- ${r.title}${r.url ? ` (${r.url})` : ''}`);
if (r.keyInsights.length > 0) {
parts.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
}
this._formatResearchSection(parts, '### External Resources', research.findings.externalResources, (item) => {
const r = item as ResearchResult['findings']['externalResources'][number];
const lines = [`- ${r.title}${r.url ? ` (${r.url})` : ''}`];
if (r.keyInsights.length > 0) {
lines.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
}
parts.push('');
}
return lines;
});
if (research.findings.codebasePatterns.length > 0) {
parts.push('### Existing Codebase Patterns');
for (const p of research.findings.codebasePatterns.slice(0, 5)) {
parts.push(`- ${p.pattern} at ${p.location}`);
}
parts.push('');
}
this._formatResearchSection(parts, '### Existing Codebase Patterns', research.findings.codebasePatterns, (item) => {
const p = item as ResearchResult['findings']['codebasePatterns'][number];
return [`- ${p.pattern} at ${p.location}`];
});
if (research.findings.technicalRecommendations.length > 0) {
parts.push('### Recommendations');
for (const r of research.findings.technicalRecommendations.slice(0, 5)) {
parts.push(`- ${r}`);
}
parts.push('');
}
this._formatResearchSection(parts, '### Recommendations', research.findings.technicalRecommendations, (item) => [
`- ${item as string}`,
]);
return parts.join('\n');
}
@@ -414,18 +448,20 @@ export class PlanOrchestrator {
const durationMs = Date.now() - startTime;
// Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: 'No JSON found',
durationMs,
});
console.log(
`[PlanOrchestrator] Research response length: ${response.length}, first 500 chars:`,
response.substring(0, 500)
);
// Extract JSON from response — try multiple strategies
const jsonStr = this._extractJsonFromResponse(response);
if (!jsonStr) {
console.error(
`[PlanOrchestrator] No JSON found in research response. Full response:`,
response.substring(0, 2000)
);
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, 'No JSON found', durationMs);
return {
success: false,
findings: {
@@ -441,17 +477,9 @@ export class PlanOrchestrator {
};
}
const parsed = tryParseJSON(jsonMatch[0]);
const parsed = tryParseJSON(jsonStr);
if (!parsed.success) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: parsed.error,
durationMs,
});
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, parsed.error!, durationMs);
return {
success: false,
findings: {
@@ -495,16 +523,8 @@ export class PlanOrchestrator {
return result;
} catch (err) {
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error,
durationMs,
});
const error = getErrorMessage(err);
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, error, durationMs);
return {
success: false,
findings: {
@@ -521,7 +541,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);
}
@@ -587,32 +607,26 @@ export class PlanOrchestrator {
const durationMs = Date.now() - startTime;
// Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: 'No JSON found',
durationMs,
});
console.log(
`[PlanOrchestrator] Planner response length: ${response.length}, first 500 chars:`,
response.substring(0, 500)
);
// Extract JSON from response — try multiple strategies
const jsonStr = this._extractJsonFromResponse(response);
if (!jsonStr) {
console.error(
`[PlanOrchestrator] No JSON found in planner response. Full response:`,
response.substring(0, 2000)
);
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, 'No JSON found', durationMs);
return { success: false, error: 'No JSON in response' };
}
const parsed = tryParseJSON(jsonMatch[0]);
const parsed = tryParseJSON(jsonStr);
if (!parsed.success) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: parsed.error,
durationMs,
});
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, parsed.error!, durationMs);
return { success: false, error: parsed.error };
}
@@ -637,21 +651,13 @@ export class PlanOrchestrator {
return { success: true, items, gaps, warnings };
} catch (err) {
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error,
durationMs,
});
const error = getErrorMessage(err);
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, error, durationMs);
return { success: false, error };
} 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
View File
@@ -7,3 +7,10 @@
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
export { PLANNER_PROMPT } from './planner.js';
export {
PHASE_EXECUTION_PROMPT,
TEAM_LEAD_PROMPT,
VERIFICATION_PROMPT,
REPLAN_PROMPT,
SINGLE_TASK_PROMPT,
} from './orchestrator.js';
+116
View File
@@ -0,0 +1,116 @@
/**
* @fileoverview Orchestrator Loop prompt templates.
*
* Templates for phase execution, team delegation, verification, and replanning.
* Placeholders use {VARIABLE} syntax and are replaced at runtime.
*
* @module prompts/orchestrator
*/
/**
* Phase execution prompt — tells Claude what to accomplish in this phase.
*
* Placeholders:
* - {PHASE_NUMBER}: Phase index (1-based)
* - {PHASE_NAME}: Human-readable phase name
* - {GOAL}: Original user goal
* - {COMPLETED_PHASES}: Summary of previously completed phases
* - {TASK_LIST}: Numbered task list for this phase
* - {VERIFICATION_CRITERIA}: What will be checked after this phase
* - {COMPLETION_PHRASE}: The phrase to output when done
*/
export const PHASE_EXECUTION_PROMPT = `You are executing {PHASE_NAME} of a larger project.
OVERALL GOAL: {GOAL}
COMPLETED SO FAR:
{COMPLETED_PHASES}
YOUR TASKS FOR THIS PHASE:
{TASK_LIST}
Complete each task thoroughly. Run tests after each change to catch issues early.
VERIFICATION (will be checked after you finish):
{VERIFICATION_CRITERIA}
When ALL tasks in this phase are complete and verified, output: <promise>{COMPLETION_PHRASE}</promise>`;
/**
* Team lead delegation prompt — instructs a lead to coordinate teammates.
*
* Placeholders:
* - {PHASE_NAME}: Phase name
* - {TASK_LIST}: Numbered task list
* - {TEAMMATE_HINTS}: Suggested teammate specializations
* - {COMPLETION_PHRASE}: Phrase for when all work is done
*/
export const TEAM_LEAD_PROMPT = `You are the team lead for {PHASE_NAME}.
Create teammates and delegate the following tasks for parallel execution:
{TASK_LIST}
Suggested teammate roles:
{TEAMMATE_HINTS}
Each teammate should focus on their assigned task area. Monitor their progress.
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
/**
* Verification prompt — asks Claude to verify phase completion.
*
* Placeholders:
* - {PHASE_NAME}: Phase name
* - {CRITERIA}: Numbered verification criteria
* - {PASS_PHRASE}: Phrase to output on success
* - {FAIL_PHRASE}: Phrase to output on failure
*/
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
{CRITERIA}
If ALL criteria are met, respond with: {PASS_PHRASE}
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
/**
* Replan prompt — gives failure context and asks for recovery.
*
* Placeholders:
* - {PHASE_NAME}: Phase name
* - {ATTEMPT_NUMBER}: Current retry attempt
* - {MAX_ATTEMPTS}: Maximum attempts allowed
* - {FAILURE_SUMMARY}: What went wrong
* - {SUGGESTIONS}: Recovery suggestions from verification
* - {ORIGINAL_TASKS}: The original task list
* - {COMPLETION_PHRASE}: Phrase for when recovery is done
*/
export const REPLAN_PROMPT = `Phase "{PHASE_NAME}" verification failed (attempt {ATTEMPT_NUMBER}/{MAX_ATTEMPTS}).
WHAT WENT WRONG:
{FAILURE_SUMMARY}
SUGGESTIONS:
{SUGGESTIONS}
ORIGINAL TASKS:
{ORIGINAL_TASKS}
Fix the issues identified above. Focus on making the verification criteria pass.
When the fixes are complete, output: <promise>{COMPLETION_PHRASE}</promise>`;
/**
* Single-task execution prompt — for phases with a single task.
*
* Placeholders:
* - {TASK}: The task description
* - {GOAL}: Original user goal
* - {CONTEXT}: Any relevant context
* - {COMPLETION_PHRASE}: Phrase for when done
*/
export const SINGLE_TASK_PROMPT = `{TASK}
Context: This is part of a larger project — {GOAL}
{CONTEXT}
When done, output: <promise>{COMPLETION_PHRASE}</promise>`;
+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;
}
+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);
}
/**
+109 -107
View File
@@ -91,6 +91,67 @@ const COMPLETION_INDICATOR_PATTERNS = [
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
];
interface FieldParser<T> {
pattern: RegExp;
field: keyof RalphStatusBlock;
validate: (value: string) => boolean;
transform: (value: string) => T;
errorMsg: (value: string) => string;
}
const FIELD_PARSERS: FieldParser<RalphStatusValue | RalphTestsStatus | RalphWorkType | number | boolean | string>[] = [
{
pattern: RALPH_STATUS_FIELD_PATTERN,
field: 'status',
validate: (v) => ['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(v.toUpperCase()),
transform: (v) => v.toUpperCase() as RalphStatusValue,
errorMsg: (v) => `Invalid STATUS value: "${v}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`,
},
{
pattern: RALPH_TASKS_COMPLETED_PATTERN,
field: 'tasksCompletedThisLoop',
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
transform: (v) => parseInt(v, 10),
errorMsg: (v) => `Invalid TASKS_COMPLETED_THIS_LOOP value: "${v}". Expected: non-negative integer`,
},
{
pattern: RALPH_FILES_MODIFIED_PATTERN,
field: 'filesModified',
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
transform: (v) => parseInt(v, 10),
errorMsg: (v) => `Invalid FILES_MODIFIED value: "${v}". Expected: non-negative integer`,
},
{
pattern: RALPH_TESTS_STATUS_PATTERN,
field: 'testsStatus',
validate: (v) => ['PASSING', 'FAILING', 'NOT_RUN'].includes(v.toUpperCase()),
transform: (v) => v.toUpperCase() as RalphTestsStatus,
errorMsg: (v) => `Invalid TESTS_STATUS value: "${v}". Expected: PASSING, FAILING, or NOT_RUN`,
},
{
pattern: RALPH_WORK_TYPE_PATTERN,
field: 'workType',
validate: (v) => ['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(v.toUpperCase()),
transform: (v) => v.toUpperCase() as RalphWorkType,
errorMsg: (v) =>
`Invalid WORK_TYPE value: "${v}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`,
},
{
pattern: RALPH_EXIT_SIGNAL_PATTERN,
field: 'exitSignal',
validate: () => true,
transform: (v) => v.toLowerCase() === 'true',
errorMsg: () => '',
},
{
pattern: RALPH_RECOMMENDATION_PATTERN,
field: 'recommendation',
validate: () => true,
transform: (v) => v.trim(),
errorMsg: () => '',
},
];
/**
* RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker.
*
@@ -303,85 +364,21 @@ export class RalphStatusParser extends EventEmitter {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Track whether this line matched any known field
let matched = false;
// STATUS field (required)
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN);
if (statusMatch) {
const value = statusMatch[1].toUpperCase();
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
block.status = value as RalphStatusValue;
} else {
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
for (const parser of FIELD_PARSERS) {
const match = trimmedLine.match(parser.pattern);
if (match) {
const rawValue = match[1];
if (parser.validate(rawValue)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(block as any)[parser.field] = parser.transform(rawValue);
} else {
parseErrors.push(parser.errorMsg(rawValue));
}
matched = true;
break;
}
matched = true;
}
// TASKS_COMPLETED_THIS_LOOP field
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
if (tasksMatch) {
const value = parseInt(tasksMatch[1], 10);
if (!Number.isNaN(value) && value >= 0) {
block.tasksCompletedThisLoop = value;
} else {
parseErrors.push(
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
);
}
matched = true;
}
// FILES_MODIFIED field
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
if (filesMatch) {
const value = parseInt(filesMatch[1], 10);
if (!Number.isNaN(value) && value >= 0) {
block.filesModified = value;
} else {
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
}
matched = true;
}
// TESTS_STATUS field
const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN);
if (testsMatch) {
const value = testsMatch[1].toUpperCase();
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
block.testsStatus = value as RalphTestsStatus;
} else {
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
}
matched = true;
}
// WORK_TYPE field
const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN);
if (workMatch) {
const value = workMatch[1].toUpperCase();
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
block.workType = value as RalphWorkType;
} else {
parseErrors.push(
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
);
}
matched = true;
}
// EXIT_SIGNAL field
const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN);
if (exitMatch) {
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
matched = true;
}
// RECOMMENDATION field
const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN);
if (recMatch) {
block.recommendation = recMatch[1].trim();
matched = true;
}
// Track unknown fields for debugging (only if looks like a field)
@@ -475,38 +472,9 @@ export class RalphStatusParser extends EventEmitter {
const prevState = this._circuitBreaker.state;
if (hasProgress) {
// Progress detected - reset counters, possibly close circuit
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = this._cycleCount;
if (this._circuitBreaker.state === 'HALF_OPEN') {
this._circuitBreaker.state = 'CLOSED';
this._circuitBreaker.reason = 'Progress detected, circuit closed';
this._circuitBreaker.reasonCode = 'progress_detected';
}
this._handleProgressDetected();
} else {
// No progress
this._circuitBreaker.consecutiveNoProgress++;
// State transitions based on consecutive no-progress
if (this._circuitBreaker.state === 'CLOSED') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
this._circuitBreaker.state = 'HALF_OPEN';
this._circuitBreaker.reason = 'Warning: no progress detected';
this._circuitBreaker.reasonCode = 'no_progress_warning';
}
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
}
}
this._handleNoProgress();
}
// Track tests failure
@@ -535,6 +503,40 @@ export class RalphStatusParser extends EventEmitter {
}
}
private _handleProgressDetected(): void {
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = this._cycleCount;
if (this._circuitBreaker.state === 'HALF_OPEN') {
this._circuitBreaker.state = 'CLOSED';
this._circuitBreaker.reason = 'Progress detected, circuit closed';
this._circuitBreaker.reasonCode = 'progress_detected';
}
}
private _handleNoProgress(): void {
this._circuitBreaker.consecutiveNoProgress++;
if (this._circuitBreaker.state === 'CLOSED') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
this._circuitBreaker.state = 'HALF_OPEN';
this._circuitBreaker.reason = 'Warning: no progress detected';
this._circuitBreaker.reasonCode = 'no_progress_warning';
}
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
}
}
}
/**
* Check line for completion indicators (natural language patterns).
* Used for dual-condition exit gate.
+75 -87
View File
@@ -55,6 +55,7 @@ import {
stringSimilarity,
Debouncer,
CleanupManager,
execPattern,
} from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
@@ -63,6 +64,7 @@ import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js';
import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// Re-export sub-module types for backward compatibility
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
@@ -72,9 +74,9 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
/**
* Todo items older than this duration (in milliseconds) will be auto-expired.
* Default: 1 hour (60 * 60 * 1000)
* Default: 1 hour
*/
const TODO_EXPIRY_MS = 60 * 60 * 1000;
const TODO_EXPIRY_MS = STALE_DATA_MAX_AGE_MS;
/**
* Minimum interval between on-demand cleanup checks (in milliseconds).
@@ -88,7 +90,7 @@ const CLEANUP_THROTTLE_MS = 30 * 1000;
* Actively purges expired todos even when no terminal data is flowing.
* Default: 5 minutes
*/
const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
/**
* Similarity threshold for todo deduplication.
@@ -98,6 +100,18 @@ const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
*/
const TODO_SIMILARITY_THRESHOLD = 0.85;
/**
* Similarity threshold for short todo content (<30 chars).
* Higher threshold reduces false positive deduplication of short strings.
*/
const SIMILARITY_THRESHOLD_SHORT = 0.95;
/**
* Similarity threshold for medium-length todo content (30-60 chars).
* Slightly relaxed compared to short strings.
*/
const SIMILARITY_THRESHOLD_MEDIUM = 0.9;
/**
* Debounce interval for event emissions (milliseconds).
* Prevents UI jitter from rapid consecutive updates.
@@ -1297,6 +1311,24 @@ export class RalphTracker extends EventEmitter {
this.detectTodoItems(trimmed);
}
/**
* Mark all tracked todos as completed and emit todoUpdate if any changed.
* @returns true if any todo was updated
*/
private completeAllTodos(): boolean {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
return updated;
}
/**
* Detect "all tasks complete" messages.
*/
@@ -1316,16 +1348,7 @@ export class RalphTracker extends EventEmitter {
return;
}
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
if (this._loopState.completionPhrase) {
this._loopState.active = false;
@@ -1423,16 +1446,7 @@ export class RalphTracker extends EventEmitter {
if (bareCount > 1) return;
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -1478,16 +1492,7 @@ export class RalphTracker extends EventEmitter {
if (canonicalCount >= 2 || this._loopState.active) {
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this.emit('completionDetected', matchedPhrase);
this.emit('loopUpdate', this.loopState);
return;
@@ -1495,16 +1500,7 @@ export class RalphTracker extends EventEmitter {
}
if (this._loopState.active || count >= 2) {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -1530,41 +1526,39 @@ export class RalphTracker extends EventEmitter {
const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'common',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'common', suggestedPhrase);
return;
}
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'short',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'short', suggestedPhrase);
return;
}
if (/^\d+$/.test(normalized)) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'numeric',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'numeric', suggestedPhrase);
}
}
/**
* Emit a phrase validation warning with a console message and event.
*/
private emitValidationWarning(phrase: string, reason: 'common' | 'short' | 'numeric', suggestedPhrase: string): void {
const descriptions: Record<'common' | 'short' | 'numeric', string> = {
common: 'is very common and may cause false positives',
short: `is too short (${phrase.toUpperCase().replace(/[\s_\-.]+/g, '').length} chars)`,
numeric: 'is numeric-only and may cause false positives',
};
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" ${descriptions[reason]}. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason,
suggestedPhrase,
});
}
/**
* Activate the loop if not already active.
*/
@@ -1666,35 +1660,32 @@ export class RalphTracker extends EventEmitter {
let match: RegExpExecArray | null;
if (hasCheckbox) {
TODO_CHECKBOX_PATTERN.lastIndex = 0;
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
execPattern(TODO_CHECKBOX_PATTERN, line, (match) => {
const checked = match[1].toLowerCase() === 'x';
const content = match[2].trim();
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
this.upsertTodo(content, status);
updated = true;
}
});
}
if (hasTodoIndicator) {
TODO_INDICATOR_PATTERN.lastIndex = 0;
while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) {
execPattern(TODO_INDICATOR_PATTERN, line, (match) => {
const icon = match[1];
const content = match[2].trim();
const status = this.iconToStatus(icon);
this.upsertTodo(content, status);
updated = true;
}
});
}
if (hasStatus) {
TODO_STATUS_PATTERN.lastIndex = 0;
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
execPattern(TODO_STATUS_PATTERN, line, (match) => {
const content = match[1].trim();
const status = match[2] as RalphTodoStatus;
this.upsertTodo(content, status);
updated = true;
}
});
}
if (hasNativeCheckbox) {
@@ -1715,8 +1706,7 @@ export class RalphTracker extends EventEmitter {
}
if (hasCheckmark) {
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
@@ -1725,10 +1715,9 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(content, 'pending');
updated = true;
}
}
});
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
@@ -1739,10 +1728,9 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
updated = true;
}
}
});
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim();
const status: RalphTodoStatus =
@@ -1752,7 +1740,7 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(content, status);
updated = true;
}
}
});
if (!updated) {
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
@@ -1981,9 +1969,9 @@ export class RalphTracker extends EventEmitter {
let threshold: number;
if (normalized.length < 30) {
threshold = 0.95;
threshold = SIMILARITY_THRESHOLD_SHORT;
} else if (normalized.length < 60) {
threshold = 0.9;
threshold = SIMILARITY_THRESHOLD_MEDIUM;
} else {
threshold = TODO_SIMILARITY_THRESHOLD;
}
+278 -197
View File
@@ -49,8 +49,7 @@ import { Session } from './session.js';
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
import {
isCompletionMessage,
@@ -62,13 +61,22 @@ 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 type {
RespawnCycleMetrics,
RespawnAggregateMetrics,
RalphLoopHealthScore,
TimingHistory,
CycleOutcome,
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 {
getErrorMessage,
type RespawnCycleMetrics,
type RespawnAggregateMetrics,
type RalphLoopHealthScore,
type TimingHistory,
type CycleOutcome,
} from './types.js';
// ========== Constants ==========
@@ -544,6 +552,14 @@ export interface RespawnEvents {
respawnBlocked: (data: { reason: string; details: string }) => void;
}
/**
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
*/
function formatRemainingSeconds(ms: number): number {
return Math.max(0, Math.ceil(ms / 1000));
}
/** Default configuration values */
const DEFAULT_CONFIG: RespawnConfig = {
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
@@ -559,13 +575,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
@@ -832,27 +848,42 @@ export class RespawnController extends EventEmitter {
private validateConfig(): void {
const c = this.config;
// Ensure timeouts are positive
if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs;
if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs;
if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs;
if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs;
if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs;
/**
* Validate that a timeout value is positive (or non-negative when allowZero is true).
* Falls back to the DEFAULT_CONFIG value if invalid.
*/
const validatePositiveTimeout = (field: keyof RespawnConfig, allowZero = false): void => {
const value = c[field] as number;
const invalid = allowZero ? value < 0 : value <= 0;
if (invalid) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(c as any)[field] = DEFAULT_CONFIG[field];
}
};
const REQUIRED_TIMEOUT_FIELDS = [
'idleTimeoutMs',
'completionConfirmMs',
'noOutputTimeoutMs',
'interStepDelayMs',
'aiIdleCheckTimeoutMs',
'aiIdleCheckMaxContext',
'aiPlanCheckTimeoutMs',
'aiPlanCheckMaxContext',
] as const;
for (const field of REQUIRED_TIMEOUT_FIELDS) {
validatePositiveTimeout(field);
}
const ALLOW_ZERO_FIELDS = ['autoAcceptDelayMs', 'aiIdleCheckCooldownMs', 'aiPlanCheckCooldownMs'] as const;
for (const field of ALLOW_ZERO_FIELDS) {
validatePositiveTimeout(field, true);
}
// Ensure completion confirm doesn't exceed no-output timeout
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
c.completionConfirmMs = c.noOutputTimeoutMs;
}
// Ensure AI check timeouts are positive
if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
// Ensure plan check timeouts are positive
if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
}
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
@@ -980,11 +1011,11 @@ export class RespawnController extends EventEmitter {
waitingFor = 'AI verdict (IDLE or WORKING)';
} else if (this._state === 'confirming_idle') {
statusText = `Confirming idle (${confidence}% confidence)`;
waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`;
waitingFor = `${formatRemainingSeconds(this.config.completionConfirmMs - msSinceLastOutput)}s more silence`;
} else if (this._state === 'watching') {
const aiState = this.aiChecker.getState();
if (aiState.status === 'cooldown') {
const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000);
const remaining = formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs());
statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
waitingFor = 'Cooldown to expire';
} else if (completionMessageDetected) {
@@ -1357,98 +1388,13 @@ export class RespawnController extends EventEmitter {
this.lastTokenChangeTime = now;
}
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
// Check this before working patterns because completion message indicates
// the work is done, even if working patterns are still in the rolling window
if (isCompletionMessage(data)) {
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// Layer 1: Completion message (PRIMARY) — checked before working patterns
if (this._detectCompletionMessage(data, now)) return;
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return;
}
// Layer 4: Working patterns
if (this._detectWorkingPattern(data, now)) return;
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return;
}
// Detect working patterns (Layer 4)
const isWorking = this.checkWorkingPattern(data);
if (isWorking) {
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
// Cancel any pending step confirmation (Claude is still working)
this.cancelStepConfirm();
// If AI check is running, cancel it (Claude is working)
if (this._state === 'ai_checking') {
this.log('Working patterns detected during AI check, cancelling');
this.aiChecker.cancel();
this.setState('watching');
}
// Cancel plan check if running (Claude started working)
if (this.planChecker.status === 'checking') {
this.log('Working patterns detected during plan check, cancelling');
this.planChecker.cancel();
}
// If we're monitoring init and work started, go to watching (no kickstart needed)
if (this._state === 'monitoring_init') {
this.log('/init triggered work, skipping kickstart');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
return;
}
// In confirming_idle or ai_checking state, substantial output cancels the flow.
// This prevents false triggers when Claude pauses briefly mid-work.
// Substantial output during confirming_idle/ai_checking cancels the flow
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
// Strip ANSI escape codes to check if there's real content
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
@@ -1469,43 +1415,137 @@ export class RespawnController extends EventEmitter {
}
}
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (hasPrompt) {
this.promptDetected = true;
this.workingDetected = false;
// Legacy fallback: prompt detection
this._detectPrompt(data);
}
// Handle legacy detection in waiting states - also use confirmation timers
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
private _detectCompletionMessage(data: string, now: number): boolean {
if (!isCompletionMessage(data)) return false;
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return true;
}
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return true;
}
private _detectWorkingPattern(data: string, now: number): boolean {
const isWorking = this.checkWorkingPattern(data);
if (!isWorking) return false;
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
// Cancel any pending step confirmation (Claude is still working)
this.cancelStepConfirm();
// If AI check is running, cancel it (Claude is working)
if (this._state === 'ai_checking') {
this.log('Working patterns detected during AI check, cancelling');
this.aiChecker.cancel();
this.setState('watching');
}
// Cancel plan check if running (Claude started working)
if (this.planChecker.status === 'checking') {
this.log('Working patterns detected during plan check, cancelling');
this.planChecker.cancel();
}
// If we're monitoring init and work started, go to watching (no kickstart needed)
if (this._state === 'monitoring_init') {
this.log('/init triggered work, skipping kickstart');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
return true;
}
private _detectPrompt(data: string): void {
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (!hasPrompt) return;
this.promptDetected = true;
this.workingDetected = false;
// Handle legacy detection in waiting states - also use confirmation timers
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
}
@@ -1791,24 +1831,28 @@ export class RespawnController extends EventEmitter {
case 'sending_init':
case 'sending_kickstart':
// For sending states, retry the send
this.log('Recovery: returning to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
this.recoveryResetToWatching('returning to watching state');
break;
default:
// Fallback: reset to watching
this.log('Recovery: fallback to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
this.recoveryResetToWatching('fallback to watching state');
}
}
/**
* Reset the controller to watching state during stuck-state recovery.
* Sets state to watching and restarts all detection timers.
*
* @param reason - Human-readable reason for the reset (logged)
*/
private recoveryResetToWatching(reason: string): void {
this.log(`Recovery: ${reason}`);
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
}
@@ -2034,6 +2078,18 @@ export class RespawnController extends EventEmitter {
return;
}
// Check for active child processes (bash tools, test suites, builds, etc.)
// These may produce no terminal output, so restart timers to retry periodically.
const activeProcesses = this.session.getActiveChildProcesses();
if (activeProcesses.length > 0) {
const names = activeProcesses.map((p) => p.command).join(', ');
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
// If AI check is disabled or errored out, fall back to direct idle confirmation
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
@@ -2044,7 +2100,7 @@ export class RespawnController extends EventEmitter {
// If on cooldown, don't start check - wait for cooldown to expire
if (this.aiChecker.isOnCooldown()) {
this.log(
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
`AI check on cooldown (${formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs())}s remaining), waiting...`
);
return;
}
@@ -2129,7 +2185,7 @@ export class RespawnController extends EventEmitter {
}
if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg);
this.setState('watching');
@@ -2191,36 +2247,15 @@ export class RespawnController extends EventEmitter {
* @fires planCheckStarted
*/
private tryAutoAccept(): void {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return;
if (!this.canAutoAccept()) return;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return;
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return;
}
// Stage 1: Pre-filter — check if buffer looks like plan mode
const buffer = this.terminalBuffer.value;
if (!this.isPlanModePreFilterMatch(buffer)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
return;
}
// Stage 2: AI confirmation (if enabled and available)
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
if (this.planChecker.isOnCooldown()) {
this.log(
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
`Skipping auto-accept: plan checker on cooldown (${formatRemainingSeconds(this.planChecker.getCooldownRemainingMs())}s remaining)`
);
return;
}
@@ -2237,6 +2272,40 @@ export class RespawnController extends EventEmitter {
this.sendAutoAcceptEnter();
}
/**
* Check whether all preconditions for auto-accept are met.
* Validates state, config, and pre-filter conditions before attempting auto-accept.
*
* @returns True if auto-accept should proceed to the AI confirmation stage
*/
private canAutoAccept(): boolean {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return false;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return false;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return false;
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return false;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return false;
}
// Stage 1: Pre-filter — check if buffer looks like plan mode
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
return false;
}
return true;
}
/**
* Check if the terminal buffer matches plan mode pre-filter patterns.
* Only checks the last 2000 chars (plan mode UI appears at the bottom).
@@ -2315,7 +2384,7 @@ export class RespawnController extends EventEmitter {
}
})
.catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
@@ -2625,6 +2694,18 @@ export class RespawnController extends EventEmitter {
return;
}
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
const activeProcesses = this.session.getActiveChildProcesses();
if (activeProcesses.length > 0) {
const names = activeProcesses.map((p) => p.command).join(', ');
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
this.logAction('detection', `Rejected: child processes running (${names})`);
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
this.log(`Idle confirmed via: ${reason}`);
const status = this.getDetectionStatus();
this.log(
+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.
+98 -51
View File
@@ -27,6 +27,57 @@ const COMPACT_COOLDOWN_MS = 10000;
/** Cooldown after clear completes before re-enabling (5 seconds) */
const CLEAR_COOLDOWN_MS = 5000;
/**
* Executes an action when the session becomes idle, retrying if currently working.
*
* @param action - The async action to execute once idle
* @param isActive - Returns whether this operation is still active (not cancelled)
* @param isWorking - Returns whether the session is currently working
* @param isStopped - Returns whether the session has been stopped
* @param retryMs - Delay between retry attempts when working
* @param cooldownMs - Delay after action completes before calling onCooldownDone
* @param setTimer - Stores the timer reference for cleanup
* @param onCooldownDone - Called after cooldown to reset state
*/
async function executeWhenIdle(
action: () => Promise<void>,
isActive: () => boolean,
isWorking: () => boolean,
isStopped: () => boolean,
retryMs: number,
cooldownMs: number,
setTimer: (timer: NodeJS.Timeout | null) => void,
onCooldownDone: () => void
): Promise<void> {
if (isStopped()) return;
if (!isActive()) return;
if (!isWorking()) {
if (isStopped()) return;
await action();
if (!isStopped()) {
setTimer(
setTimeout(() => {
if (isStopped()) return;
setTimer(null);
onCooldownDone();
}, cooldownMs)
);
}
} else {
if (!isStopped()) {
setTimer(
setTimeout(
() => executeWhenIdle(action, isActive, isWorking, isStopped, retryMs, cooldownMs, setTimer, onCooldownDone),
retryMs
)
);
}
}
}
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
@@ -181,37 +232,35 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
);
const checkAndCompact = async () => {
if (this.callbacks.isStopped()) return;
if (!this._isCompacting) return;
if (!this.callbacks.isWorking()) {
if (this.callbacks.isStopped()) return;
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
});
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(() => {
if (this.callbacks.isStopped()) return;
this._autoCompactTimer = null;
this._isCompacting = false;
}, COMPACT_COOLDOWN_MS);
}
} else {
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
}
}
const action = async () => {
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
});
};
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
this._autoCompactTimer = setTimeout(
() =>
executeWhenIdle(
action,
() => this._isCompacting,
() => this.callbacks.isWorking(),
() => this.callbacks.isStopped(),
AUTO_RETRY_DELAY_MS,
COMPACT_COOLDOWN_MS,
(timer) => {
this._autoCompactTimer = timer;
},
() => {
this._isCompacting = false;
}
),
AUTO_INITIAL_DELAY_MS
);
}
}
}
@@ -231,32 +280,30 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
);
const checkAndClear = async () => {
if (this.callbacks.isStopped()) return;
if (!this._isClearing) return;
if (!this.callbacks.isWorking()) {
if (this.callbacks.isStopped()) return;
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(() => {
if (this.callbacks.isStopped()) return;
this._autoClearTimer = null;
this._isClearing = false;
}, CLEAR_COOLDOWN_MS);
}
} else {
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
}
}
const action = async () => {
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
};
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
this._autoClearTimer = setTimeout(
() =>
executeWhenIdle(
action,
() => this._isClearing,
() => this.callbacks.isWorking(),
() => this.callbacks.isStopped(),
AUTO_RETRY_DELAY_MS,
CLEAR_COOLDOWN_MS,
(timer) => {
this._autoClearTimer = timer;
},
() => {
this._isClearing = false;
}
),
AUTO_INITIAL_DELAY_MS
);
}
}
}
+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.
+274 -260
View File
@@ -29,6 +29,7 @@
*/
import { EventEmitter } from 'node:events';
import { execSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import {
@@ -40,6 +41,7 @@ import {
ActiveBashTool,
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
@@ -48,8 +50,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,
@@ -58,6 +66,7 @@ import {
MAX_MESSAGES,
MAX_LINE_BUFFER_SIZE,
} from './config/buffer-limits.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import {
buildInteractiveArgs,
buildPromptArgs,
@@ -318,6 +327,7 @@ export class Session extends EventEmitter {
// OpenCode configuration (only for mode === 'opencode')
private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -376,6 +386,8 @@ export class Session extends EventEmitter {
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
}
) {
super();
@@ -392,12 +404,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;
@@ -531,6 +541,49 @@ export class Session extends EventEmitter {
return this._isWorking;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
*
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
* case where bash wraps claude and didn't exec).
*
* Returns an array of {pid, command} for each child process, or empty array if none.
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
*/
getActiveChildProcesses(): { pid: number; command: string }[] {
if (!this._muxSession) return [];
try {
const panePid = this._muxSession.pid;
// Single call: get direct children with their command names
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (!output) return [];
const activeProcesses: { pid: number; command: string }[] = [];
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(.+)/);
if (!match) continue;
const pid = parseInt(match[1], 10);
const command = match[2].trim();
// Skip the claude process itself (pane_pid may be bash wrapping claude)
if (command === 'claude') continue;
activeProcesses.push({ pid, command });
}
return activeProcesses;
} catch {
// ps returns exit code 1 when no matches — normal (no children)
return [];
}
}
get lastPromptTime(): number {
return this._lastPromptTime;
}
@@ -786,6 +839,7 @@ export class Session extends EventEmitter {
cliAccountType: this._cliAccountType || undefined,
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
};
}
@@ -863,18 +917,77 @@ export class Session extends EventEmitter {
* session.write('help me with this code\r');
* ```
*/
private async _setupOrAttachMuxSession(options: {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean }> {
const mux = this._mux!;
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await mux.respawnPane(options.respawnPaneOptions);
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestored = this._muxSession !== null && !needsNewSession;
if (isRestored) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await mux.createSession(options.createSessionOptions);
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
});
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
return { isRestored };
}
private _handleTerminalOutput(data: string): void {
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
}
async startInteractive(): Promise<void> {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
console.log(
@@ -884,18 +997,8 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({
const { isRestored } = await this._setupOrAttachMuxSession({
respawnPaneOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -904,23 +1007,9 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
});
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestoredSession = this._muxSession !== null && !needsNewSession;
if (isRestoredSession) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await this._mux.createSession({
resumeSessionId: this._resumeSessionId,
},
createSessionOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -930,37 +1019,17 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
});
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
resumeSessionId: this._resumeSessionId,
},
spawnErrLabel: 'mux attachment',
});
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName),
{
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
}
);
// Set claudeSessionId immediately since we passed --session-id to Claude
// The mux manager passes --session-id ${sessionId} to Claude
this._claudeSessionId = 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}`);
throw spawnErr;
}
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
if (!isRestoredSession) {
if (!isRestored) {
if (this.mode === 'opencode') {
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
// Wait for TUI to stabilize (output stops changing), then mark ready.
@@ -1036,9 +1105,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);
@@ -1048,12 +1116,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
// === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt
@@ -1249,13 +1312,7 @@ export class Session extends EventEmitter {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
// Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash';
@@ -1267,69 +1324,26 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
// Verify stale mux session — tmux may have been destroyed externally
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({
const { isRestored } = await this._setupOrAttachMuxSession({
respawnPaneOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: 'shell',
niceConfig: this._niceConfig,
});
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestoredSession = this._muxSession !== null && !needsNewSession;
if (isRestoredSession) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await this._mux.createSession({
},
createSessionOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: 'shell',
name: this._name,
niceConfig: this._niceConfig,
});
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName),
{
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
}
);
} catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
},
spawnErrLabel: 'shell mux attachment',
});
// For NEW sessions: clear by sending 'clear' command to the shell
// For RESTORED sessions: don't clear - we want to see the existing output
if (!isRestoredSession) {
if (!isRestored) {
setTimeout(() => {
if (this.ptyProcess) {
this._terminalBuffer.clear();
@@ -1370,12 +1384,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
});
this.ptyProcess.onExit(({ exitCode }) => {
@@ -1440,13 +1449,7 @@ export class Session extends EventEmitter {
return;
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve;
@@ -1489,12 +1492,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
// Also try to parse JSON lines for structured data
this.processOutput(data);
@@ -1526,9 +1524,11 @@ export class Session extends EventEmitter {
this._status = 'idle';
const cost = resultMsg.total_cost_usd || 0;
this._totalCost += cost;
this.emit('completion', resultMsg.result || '', cost);
// Claude CLI stream-json may return empty result field — fall back to accumulated text output
const result = resultMsg.result || this._textOutput.value || '';
this.emit('completion', result, cost);
if (resolve) {
resolve({ result: resultMsg.result || '', cost });
resolve({ result, cost });
}
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
this._status = 'error';
@@ -1557,6 +1557,117 @@ export class Session extends EventEmitter {
});
}
private _resetBuffers(): void {
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
}
private _clearAllTimers(): void {
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Destroy auto-compact/auto-clear automation (clears its timers)
this._autoOps.destroy();
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Clear expensive processing timer
if (this._expensiveProcessTimer) {
clearTimeout(this._expensiveProcessTimer);
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
}
private _handleJsonMessage(cleanLine: string, rawLine: string): void {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage (with validation)
if (msg.message.usage) {
const inputDelta = msg.message.usage.input_tokens || 0;
const outputDelta = msg.message.usage.output_tokens || 0;
// Sanity check: max 100k tokens per message (generous limit)
const MAX_TOKENS_PER_MESSAGE = 100_000;
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalInputTokens += inputDelta;
}
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalOutputTokens += outputDelta;
}
// Check if we should auto-compact or auto-clear
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(rawLine + '\n');
}
}
private processOutput(data: string): void {
// Early return if session is stopped to prevent any processing or timer creation
if (this._isStopped) return;
@@ -1598,64 +1709,7 @@ export class Session extends EventEmitter {
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage (with validation)
if (msg.message.usage) {
const inputDelta = msg.message.usage.input_tokens || 0;
const outputDelta = msg.message.usage.output_tokens || 0;
// Sanity check: max 100k tokens per message (generous limit)
const MAX_TOKENS_PER_MESSAGE = 100_000;
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalInputTokens += inputDelta;
}
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalOutputTokens += outputDelta;
}
// Check if we should auto-compact or auto-clear
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(line + '\n');
}
this._handleJsonMessage(cleanLine, line);
} else if (trimmed) {
this._textOutput.append(line + '\n');
}
@@ -1692,16 +1746,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);
}
}
});
}
/**
@@ -1951,7 +2001,7 @@ export class Session extends EventEmitter {
this._status = 'busy';
this._lastActivityAt = Date.now();
this.runPrompt(input).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
// Clean up task state so the task queue doesn't get stuck
if (this._currentTaskId) {
const taskId = this._currentTaskId;
@@ -2026,43 +2076,7 @@ export class Session extends EventEmitter {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Destroy auto-compact/auto-clear automation (clears its timers)
this._autoOps.destroy();
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Clear expensive processing timer
if (this._expensiveProcessTimer) {
clearTimeout(this._expensiveProcessTimer);
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
this._clearAllTimers();
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if mux kill times out)
+96 -78
View File
@@ -116,6 +116,26 @@ export class StateStore {
this.loadRalphStates();
}
private _mergeWithInitialState(parsed: Partial<AppState>): AppState {
const initial = createInitialState();
return {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
}
private _resetCircuitBreaker(): void {
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
}
private ensureDir(): void {
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
@@ -132,15 +152,7 @@ export class StateStore {
if (existsSync(path)) {
const data = readFileSync(path, 'utf-8');
const parsed = JSON.parse(data) as Partial<AppState>;
const initial = createInitialState();
const result = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
const result = this._mergeWithInitialState(parsed);
if (path !== this.filePath) {
console.warn(`[StateStore] Recovered state from backup: ${path}`);
}
@@ -195,16 +207,7 @@ export class StateStore {
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
*/
private assembleStateJson(): string {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
this.updateDirtySessionCache();
// Build sessions object from cached fragments
const sessionParts: string[] = [];
@@ -218,6 +221,25 @@ export class StateStore {
sessionParts.push(`${JSON.stringify(id)}:${json}`);
}
this.pruneStaleCacheEntries();
return this.buildPartialJson(sessionParts);
}
private updateDirtySessionCache(): void {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
}
private pruneStaleCacheEntries(): void {
// Prune stale cache entries (sessions removed via direct state mutation)
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
for (const cachedId of this.cachedSessionJsons.keys()) {
@@ -226,7 +248,9 @@ export class StateStore {
}
}
}
}
private buildPartialJson(sessionParts: string[]): string {
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
const sessionsJson = `{${sessionParts.join(',')}}`;
@@ -249,6 +273,28 @@ export class StateStore {
return `{${parts.join(',')}}`;
}
private serializeState(): string | null {
try {
return this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
return JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return null;
}
}
}
private async _doSaveAsync(): Promise<void> {
this.saveDeb.cancel();
if (!this.dirty) {
@@ -265,28 +311,10 @@ export class StateStore {
const tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
let json: string;
// Step 1: Serialize state (validates it's JSON-safe)
try {
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return;
}
}
const json = this.serializeState();
if (json === null) return;
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
// The state snapshot is already captured in `json` above.
@@ -305,11 +333,7 @@ export class StateStore {
await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath);
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
this._resetCircuitBreaker();
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
// Re-mark dirty so the data is retried on the next save cycle
@@ -351,27 +375,9 @@ export class StateStore {
const tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
let json: string;
try {
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return;
}
}
const json = this.serializeState();
if (json === null) return;
// Backup via atomic copy (avoids reading entire file into memory)
try {
@@ -387,11 +393,7 @@ export class StateStore {
renameSync(tempPath, this.filePath);
// Clear dirty flag only AFTER successful write
this.dirty = false;
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
this._resetCircuitBreaker();
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++;
@@ -417,15 +419,7 @@ export class StateStore {
if (existsSync(backupPath)) {
const backupContent = readFileSync(backupPath, 'utf-8');
const parsed = JSON.parse(backupContent) as Partial<AppState>;
const initial = createInitialState();
this.state = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
this.state = this._mergeWithInitialState(parsed);
console.log('[StateStore] Successfully recovered state from backup');
// Reset circuit breaker after successful recovery
this.circuitBreakerOpen = false;
@@ -547,6 +541,30 @@ export class StateStore {
this.save();
}
// ========== Orchestrator Loop State Methods ==========
/** Returns the orchestrator loop state, or null if never initialized. */
getOrchestratorState() {
return this.state.orchestrator ?? null;
}
/** Updates orchestrator loop state (partial merge) and triggers a debounced save. */
setOrchestratorState(orchestrator: Partial<NonNullable<AppState['orchestrator']>>) {
if (this.state.orchestrator) {
this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator };
} else {
// First initialization — caller must provide full state
this.state.orchestrator = orchestrator as NonNullable<AppState['orchestrator']>;
}
this.save();
}
/** Clears orchestrator state and triggers a debounced save. */
clearOrchestratorState() {
this.state.orchestrator = undefined;
this.save();
}
/** Returns the application configuration. */
getConfig() {
return this.state.config;
+260 -222
View File
@@ -17,7 +17,7 @@
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
*
* @dependencies config/map-limits (MAX_TRACKED_AGENTS, PENDING_TOOL_CALL_TTL_MS),
* utils (CleanupManager, KeyedDebouncer)
* config/buffer-limits (FILE_PEEK_BYTES), utils (CleanupManager, KeyedDebouncer)
* @consumedby web/server (SSE broadcast), session (subagent-session correlation)
* @emits subagent:discovered, subagent:updated, subagent:tool_call, subagent:tool_result,
* subagent:progress, subagent:message, subagent:completed
@@ -34,6 +34,8 @@ import { join, basename } from 'node:path';
import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
import { FILE_PEEK_BYTES } from './config/buffer-limits.js';
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
@@ -151,7 +153,7 @@ const POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks)
const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s)
const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s
const FILE_ALIVE_THRESHOLD_MS = 30000; // File mtime within 30s = agent alive (primary check)
const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour
const STALE_COMPLETED_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS; // Remove completed agents older than 1 hour
const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours
const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup
@@ -220,6 +222,83 @@ export class SubagentWatcher extends EventEmitter {
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
}
/**
* Mark a subagent as completed: clear PID, set status, clean up pending tool calls, emit event.
*/
private markSubagentAsCompleted(info: SubagentInfo): void {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
}
/**
* Extract text from message content, handling both string and array formats.
* For array content, returns the text from the first 'text' block.
*/
private extractFirstTextContent(
content: string | Array<{ type: string; text?: string }> | undefined
): string | undefined {
if (!content) return undefined;
if (typeof content === 'string') {
const trimmed = content.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(content)) {
const firstContent = content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const trimmed = firstContent.text.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
}
return undefined;
}
/**
* Process a tool_result content block: look up pending tool call, emit tool_result event.
*/
private emitToolResult(
content: { tool_use_id: string; content?: string | Array<{ type: string; text?: string }>; is_error?: boolean },
agentId: string,
sessionId: string,
timestamp: string
): void {
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
agentId,
sessionId,
timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
};
this.emit('subagent:tool_result', toolResult);
}
/**
* Find the oldest inactive (non-active) agent for LRU eviction.
* Returns the agent ID of the oldest inactive agent, or null if all are active.
*/
private findOldestInactiveAgent(): string | null {
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, existing] of this.agentInfo) {
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
oldestTime = existing.lastActivityAt;
oldestId = id;
}
}
return oldestId;
}
/**
* Extract short model identifier from full model name
*/
@@ -305,10 +384,7 @@ export class SubagentWatcher extends EventEmitter {
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
if (!alive) {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
}
}
}
@@ -675,10 +751,7 @@ export class SubagentWatcher extends EventEmitter {
const pid = await this.findSubagentProcess(info.sessionId);
if (pid) {
process.kill(pid, 'SIGTERM');
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
return true;
}
} catch {
@@ -686,10 +759,7 @@ export class SubagentWatcher extends EventEmitter {
}
// Mark as completed even if we couldn't find the process
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
return true;
}
@@ -841,19 +911,9 @@ export class SubagentWatcher extends EventEmitter {
}
} else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
} else {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const text = firstContent.text.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
}
const text = this.extractFirstTextContent(entry.message.content);
if (text && text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
}
}
@@ -928,6 +988,24 @@ export class SubagentWatcher extends EventEmitter {
return truncated.replace(/[.!?,:\s]+$/, '');
}
private async _resolveDescription(
projectHash: string,
sessionId: string,
agentId: string,
filePath: string,
fallbackText?: string
): Promise<string | undefined> {
// First try parent transcript (most reliable)
const fromParent = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
if (fromParent) return fromParent;
// Fallback: inline text (from processEntry) or file extraction
if (fallbackText) {
return this.extractSmartTitle(fallbackText);
}
return this.extractDescriptionFromFile(filePath);
}
/**
* Extract the short description from the parent session's transcript.
* This is the most reliable method because it reads the actual Task tool result
@@ -1008,7 +1086,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) => {
@@ -1025,15 +1103,7 @@ export class SubagentWatcher extends EventEmitter {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
let text: string | undefined;
if (typeof entry.message.content === 'string') {
text = entry.message.content.trim();
} else if (Array.isArray(entry.message.content)) {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
text = firstContent.text.trim();
}
}
const text = this.extractFirstTextContent(entry.message.content);
if (text) {
resolved = true;
rl.close();
@@ -1140,10 +1210,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
}
});
});
@@ -1193,18 +1263,13 @@ export class SubagentWatcher extends EventEmitter {
// Retry description extraction if missing (race condition fix)
if (!existingInfo.description) {
// First try parent transcript (most reliable)
let extractedDescription = await this.extractDescriptionFromParentTranscript(
const extractedDescription = await this._resolveDescription(
existingInfo.projectHash,
existingInfo.sessionId,
agentId
agentId,
filePath
);
// Fallback to subagent file
if (!extractedDescription) {
extractedDescription = await this.extractDescriptionFromFile(filePath);
}
if (extractedDescription) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(extractedDescription)) {
this.removeAgent(agentId);
return;
@@ -1255,13 +1320,7 @@ export class SubagentWatcher extends EventEmitter {
}
// Extract description - prefer reading from parent transcript (most reliable)
// The parent transcript has the exact Task tool call with description parameter
let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
if (!description) {
description = await this.extractDescriptionFromFile(filePath);
}
const description = await this._resolveDescription(projectHash, sessionId, agentId, filePath);
// Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents
if (this.isInternalAgent(description)) {
@@ -1284,14 +1343,7 @@ export class SubagentWatcher extends EventEmitter {
// Enforce MAX_TRACKED_AGENTS during insertion — evict oldest inactive agent
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, existing] of this.agentInfo) {
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
oldestTime = existing.lastActivityAt;
oldestId = id;
}
}
const oldestId = this.findOldestInactiveAgent();
if (oldestId) {
this.removeAgent(oldestId);
}
@@ -1361,51 +1413,11 @@ export class SubagentWatcher extends EventEmitter {
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
const info = this.agentInfo.get(agentId);
// Extract model from assistant messages (first one sets the model)
if (info && entry.type === 'assistant' && entry.message?.model && !info.model) {
info.model = entry.message.model;
info.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', info);
}
if (info) {
this._processModelInfo(entry, info);
this._processTokenInfo(entry, info);
// Aggregate token usage from messages
if (info && entry.message?.usage) {
if (entry.message.usage.input_tokens) {
info.totalInputTokens = (info.totalInputTokens || 0) + entry.message.usage.input_tokens;
}
if (entry.message.usage.output_tokens) {
info.totalOutputTokens = (info.totalOutputTokens || 0) + entry.message.usage.output_tokens;
}
}
// Check if this is first user message and description is missing
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
// First try parent transcript (most reliable)
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
// Fallback: extract smart title from the prompt content
if (!description) {
let text: string | undefined;
if (typeof entry.message.content === 'string') {
text = entry.message.content.trim();
} else if (Array.isArray(entry.message.content)) {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
text = firstContent.text.trim();
}
}
if (text) {
description = this.extractSmartTitle(text);
}
}
if (description) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(description)) {
this.removeAgent(agentId);
return;
}
info.description = description;
this.emit('subagent:updated', info);
}
if (await this._processDescription(entry, agentId, info)) return;
}
if (entry.type === 'progress' && entry.data) {
@@ -1416,7 +1428,6 @@ export class SubagentWatcher extends EventEmitter {
progressType: entry.data.type,
query: entry.data.query,
resultCount: entry.data.resultCount,
// Extract hook event info if present
hookEvent: entry.data.hookEvent,
hookName:
entry.data.hookName ||
@@ -1426,139 +1437,166 @@ export class SubagentWatcher extends EventEmitter {
};
this.emit('subagent:progress', progress);
} else if (entry.type === 'assistant' && entry.message?.content) {
// Handle both string and array content formats
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length > 0) {
const message: SubagentMessage = {
this._processAssistantContent(entry, agentId, sessionId);
} else if (entry.type === 'user' && entry.message?.content) {
this._processUserContent(entry, agentId, sessionId);
}
}
private _processModelInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
if (entry.type === 'assistant' && entry.message?.model && !agent.model) {
agent.model = entry.message.model;
agent.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', agent);
}
}
private _processTokenInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
if (!entry.message?.usage) return;
if (entry.message.usage.input_tokens) {
agent.totalInputTokens = (agent.totalInputTokens || 0) + entry.message.usage.input_tokens;
}
if (entry.message.usage.output_tokens) {
agent.totalOutputTokens = (agent.totalOutputTokens || 0) + entry.message.usage.output_tokens;
}
}
private async _processDescription(
entry: SubagentTranscriptEntry,
agentId: string,
agent: SubagentInfo
): Promise<boolean> {
if (agent.description || entry.type !== 'user' || !entry.message?.content) return false;
const fallbackText = this.extractFirstTextContent(entry.message.content);
const description = await this._resolveDescription(
agent.projectHash,
agent.sessionId,
agentId,
agent.filePath,
fallbackText
);
if (description) {
if (this.isInternalAgent(description)) {
this.removeAgent(agentId);
return true;
}
agent.description = description;
this.emit('subagent:updated', agent);
}
return false;
}
private _processAssistantContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
const messageContent = entry.message!.content;
if (typeof messageContent === 'string') {
const text = messageContent.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
};
this.emit('subagent:message', message);
}
} else {
for (const content of messageContent) {
if (content.type === 'tool_use' && content.name) {
// Store toolUseId for linking to results, with timestamp for TTL cleanup
if (content.id) {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// FIFO eviction: delete first (oldest) entry using Map insertion order
const firstKey = agentCalls.keys().next().value;
if (firstKey !== undefined) agentCalls.delete(firstKey);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
}
const toolCall: SubagentToolCall = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
tool: content.name,
input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
};
this.emit('subagent:message', message);
}
} else {
for (const content of entry.message.content) {
if (content.type === 'tool_use' && content.name) {
// Store toolUseId for linking to results, with timestamp for TTL cleanup
if (content.id) {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// FIFO eviction: delete first (oldest) entry using Map insertion order
const firstKey = agentCalls.keys().next().value;
if (firstKey !== undefined) agentCalls.delete(firstKey);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
}
this.emit('subagent:tool_call', toolCall);
const toolCall: SubagentToolCall = {
// Update tool call count
const agentInfo = this.agentInfo.get(agentId);
if (agentInfo) {
agentInfo.toolCallCount++;
}
} else if (content.type === 'tool_result' && content.tool_use_id) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
agentId,
sessionId,
entry.timestamp
);
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
tool: content.name,
input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
};
this.emit('subagent:tool_call', toolCall);
// Update tool call count
const agentInfo = this.agentInfo.get(agentId);
if (agentInfo) {
agentInfo.toolCallCount++;
}
} else if (content.type === 'tool_result' && content.tool_use_id) {
// Extract tool result
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
agentId,
sessionId,
timestamp: entry.timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
};
this.emit('subagent:tool_result', toolResult);
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT), // Limit text length
};
this.emit('subagent:message', message);
}
this.emit('subagent:message', message);
}
}
}
} else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats - also check for tool_result in user messages
if (typeof entry.message.content === 'string') {
const userText = entry.message.content.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
}
}
private _processUserContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
const messageContent = entry.message!.content;
if (typeof messageContent === 'string') {
const userText = messageContent.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
} else {
// Check for tool_result blocks in user messages (common pattern)
for (const content of messageContent) {
if (content.type === 'tool_result' && content.tool_use_id) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
} else {
// Check for tool_result blocks in user messages (common pattern)
for (const content of entry.message.content) {
if (content.type === 'tool_result' && content.tool_use_id) {
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
entry.timestamp
);
} else if (content.type === 'text' && content.text) {
const userText = content.text.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
role: 'user',
text: userText,
};
this.emit('subagent:tool_result', toolResult);
} else if (content.type === 'text' && content.text) {
const userText = content.text.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
this.emit('subagent:message', message);
}
}
}
+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) {
+149 -123
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
// ============================================================================
@@ -104,6 +98,9 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
/**
* Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs.
@@ -117,23 +114,7 @@ function isValidMuxName(name: string): boolean {
* Prevents command injection via malformed paths.
*/
function isValidPath(path: string): boolean {
if (
path.includes(';') ||
path.includes('&') ||
path.includes('|') ||
path.includes('$') ||
path.includes('`') ||
path.includes('(') ||
path.includes(')') ||
path.includes('{') ||
path.includes('}') ||
path.includes('<') ||
path.includes('>') ||
path.includes("'") ||
path.includes('"') ||
path.includes('\n') ||
path.includes('\r')
) {
if (UNSAFE_PATH_CHARS.test(path)) {
return false;
}
if (path.includes('..')) {
@@ -200,12 +181,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);
@@ -365,12 +358,69 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* Build the array of environment export commands shared by createSession() and respawnPane().
* Includes locale, mux markers, session identity, and API URL.
*/
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
return exports;
}
/**
* Resolve the CLI binary directory and return the PATH export prefix string.
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
* In createSession(), a missing binary dir throws — the caller handles that separately.
*/
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
if (mode === 'claude') {
const dir = findClaudeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'opencode') {
const dir = resolveOpenCodeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
/**
* Configure OpenCode-specific environment on a tmux session.
* Sets sensitive API keys and config content via tmux setenv
* (not visible in ps output or tmux history, inherited by panes).
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* 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)) {
@@ -398,33 +448,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Resolve CLI binary directory based on mode
let pathExport = '';
if (mode === 'claude') {
const claudeDir = findClaudeDir();
if (!claudeDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
}
pathExport = `export PATH="${claudeDir}:$PATH" && `;
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
if (!openCodeDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
if (mode === 'claude' && !cliDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
}
if (mode === 'opencode' && !cliDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && ');
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
@@ -433,6 +465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -471,8 +504,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// For OpenCode: set sensitive env vars and config via tmux setenv
// (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
this._configureOpenCode(muxName, openCodeConfig);
}
// Replace the shell with the actual command (no echo in terminal)
@@ -605,7 +637,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;
@@ -613,26 +655,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
// Resolve CLI binary directory based on mode
let pathExport = '';
if (mode === 'claude') {
const claudeDir = findClaudeDir();
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
}
const { pathExport } = this.buildPathExport(mode);
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && ');
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
@@ -641,6 +666,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
@@ -649,8 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
this._configureOpenCode(muxName, openCodeConfig);
}
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
@@ -876,13 +901,34 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = [];
const discovered: string[] = [];
// Check known sessions
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
const activeSessions = new Map<string, number>();
try {
const output = execSync("tmux list-panes -a -F '#{session_name}\t#{pane_pid}' 2>/dev/null || true", {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf('\t');
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
activeSessions.set(name, pid);
}
}
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
}
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session)
for (const [sessionId, session] of this.sessions) {
if (this.sessionExists(session.muxName)) {
const pid = activeSessions.get(session.muxName);
if (pid !== undefined) {
alive.push(sessionId);
// Update PID if it changed
const pid = this.getPanePid(session.muxName);
if (pid && pid !== session.pid) {
if (pid !== session.pid) {
session.pid = pid;
}
} else {
@@ -892,51 +938,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Discover unknown codeman sessions
try {
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
// Discover unknown codeman/claudeman sessions from the same batch result
const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName);
}
for (const line of output.split('\n')) {
const sessionName = line.trim();
if (!sessionName || (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-'))) continue;
for (const [sessionName, pid] of activeSessions) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue;
// Check if this session is already known
let isKnown = false;
for (const session of this.sessions.values()) {
if (session.muxName === sessionName) {
isKnown = true;
break;
}
}
if (!isKnown) {
// Extract session ID fragment from name
const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
const sessionId = `restored-${fragment}`;
const pid = this.getPanePid(sessionName);
if (pid) {
const session: MuxSession = {
sessionId,
muxName: sessionName,
pid,
createdAt: Date.now(),
workingDir: process.cwd(),
mode: 'claude',
attached: false,
name: `Restored: ${sessionName}`,
};
this.sessions.set(sessionId, session);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
}
}
}
} catch (err) {
console.error('[TmuxManager] Failed to discover sessions:', err);
const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
const sessionId = `restored-${fragment}`;
const session: MuxSession = {
sessionId,
muxName: sessionName,
pid,
createdAt: Date.now(),
workingDir: process.cwd(),
mode: 'claude',
attached: false,
name: `Restored: ${sessionName}`,
};
this.sessions.set(sessionId, session);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
}
if (dead.length > 0 || discovered.length > 0) {
+2 -1
View File
@@ -29,6 +29,7 @@ import {
RESTART_DELAY_MS,
FORCE_KILL_MS,
} from './config/tunnel-config.js';
import { getErrorMessage } from './types.js';
// ========== Types ==========
@@ -164,7 +165,7 @@ export class TunnelManager extends EventEmitter {
detached: false,
});
} catch (err) {
this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`);
this.emit('error', `Failed to spawn cloudflared: ${getErrorMessage(err)}`);
return;
}
+1 -1
View File
@@ -117,7 +117,7 @@ export interface CaseInfo {
* @param value The value to check
* @returns True if the value is an Error instance
*/
export function isError(value: unknown): value is Error {
function isError(value: unknown): value is Error {
return value instanceof Error;
}
+2
View File
@@ -109,6 +109,8 @@ export interface AppState {
globalStats?: GlobalStats;
/** Daily token usage statistics */
tokenStats?: TokenStats;
/** Orchestrator Loop state (phased plan execution) */
orchestrator?: import('./orchestrator.js').OrchestratorPersistState;
}
// ========== Default Configuration ==========
+2
View File
@@ -26,6 +26,7 @@
* | 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` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
*
* ## Cross-domain relationship map
*
@@ -64,3 +65,4 @@ export * from './tools.js';
export * from './teams.js';
export * from './push.js';
export * from './plan.js';
export * from './orchestrator.js';
+285
View File
@@ -0,0 +1,285 @@
/**
* @fileoverview Orchestrator Loop type definitions.
*
* Types for the phased plan execution system: state machine, plan structure,
* phase grouping, team strategies, verification, configuration, and persistence.
*
* Key exports:
* - OrchestratorState — state machine states (idle → planning → approval → executing → verifying → ...)
* - OrchestratorPlan / OrchestratorPhase / OrchestratorTask — hierarchical plan structure
* - TeamStrategy — how agents are coordinated per phase (single, parallel, team)
* - VerificationResult / VerificationCheck — phase verification output
* - OrchestratorConfig — user-configurable options
* - OrchestratorPersistState / OrchestratorStats — persistence and metrics
*
* Cross-domain relationships:
* - OrchestratorTask.queueTaskId links to TaskState.id (task domain)
* - OrchestratorTask.assignedSessionId links to SessionState.id (session domain)
* - OrchestratorPersistState is embedded in AppState.orchestrator (app-state domain)
*
* Served at `GET /api/orchestrator/status` and `GET /api/orchestrator/plan`.
* No dependencies on other domain modules.
*/
// ═══════════════════════════════════════════════════════════════
// State Machine
// ═══════════════════════════════════════════════════════════════
/** Orchestrator loop states */
export type OrchestratorState =
| 'idle'
| 'planning'
| 'approval'
| 'executing'
| 'verifying'
| 'replanning'
| 'completed'
| 'failed'
| 'paused';
// ═══════════════════════════════════════════════════════════════
// Plan Structure
// ═══════════════════════════════════════════════════════════════
/** Top-level orchestrator plan generated from a user goal */
export interface OrchestratorPlan {
/** Unique plan identifier */
id: string;
/** Original user goal/task description */
goal: string;
/** When the plan was generated */
createdAt: number;
/** Ordered list of execution phases */
phases: OrchestratorPhase[];
/** Plan generation metadata */
metadata: OrchestratorPlanMetadata;
}
/** Metadata from plan generation */
export interface OrchestratorPlanMetadata {
/** Total tasks across all phases */
totalTasks: number;
/** Estimated overall complexity */
estimatedComplexity: 'low' | 'medium' | 'high';
/** Model used for plan generation */
modelUsed: string;
/** Time taken to generate the plan */
planDurationMs: number;
}
/** A sequential execution phase containing parallel tasks */
export interface OrchestratorPhase {
/** Phase identifier (e.g., "phase-1") */
id: string;
/** Human-readable phase name */
name: string;
/** Detailed description of what this phase accomplishes */
description: string;
/** Execution order (0-based) */
order: number;
/** Current phase status */
status: PhaseStatus;
/** Tasks within this phase */
tasks: OrchestratorTask[];
/** Criteria to verify after phase completion */
verificationCriteria: string[];
/** Shell commands to run for verification */
testCommands: string[];
/** Maximum retry attempts for this phase */
maxAttempts: number;
/** Current attempt count */
attempts: number;
/** When execution started */
startedAt: number | null;
/** When phase completed (passed or failed) */
completedAt: number | null;
/** Total execution duration */
durationMs: number | null;
/** How to coordinate agents for this phase */
teamStrategy: TeamStrategy;
}
/** Phase execution status */
export type PhaseStatus = 'pending' | 'executing' | 'verifying' | 'passed' | 'failed' | 'skipped';
/** A single executable task within a phase */
export interface OrchestratorTask {
/** Task identifier (e.g., "phase-1-task-1") */
id: string;
/** Parent phase identifier */
phaseId: string;
/** Single-line prompt to send to Claude */
prompt: string;
/** Current task status */
status: 'pending' | 'running' | 'completed' | 'failed';
/** Session running this task */
assignedSessionId: string | null;
/** Links to TaskQueue task ID (for completion tracking) */
queueTaskId: string | null;
/** Whether this task can run in parallel with siblings */
parallel: boolean;
/** Unique phrase for completion detection */
completionPhrase: string;
/** Timeout in milliseconds */
timeoutMs: number;
/** When task started executing */
startedAt: number | null;
/** When task completed */
completedAt: number | null;
/** Error message if task failed */
error: string | null;
/** Number of retry attempts */
retries: number;
}
// ═══════════════════════════════════════════════════════════════
// Team Strategy
// ═══════════════════════════════════════════════════════════════
/** How agents are coordinated for a phase */
export type TeamStrategy =
| { type: 'single' }
| { type: 'parallel'; maxSessions: number }
| { type: 'team'; config: TeamSetup };
/** Configuration for team-based phase execution */
export interface TeamSetup {
/** Prompt to send to the team lead */
leadPrompt: string;
/** Suggested teammate role descriptions */
suggestedTeammates: string[];
/** Maximum number of teammates to create */
maxTeammates: number;
}
// ═══════════════════════════════════════════════════════════════
// Verification
// ═══════════════════════════════════════════════════════════════
/** Result of phase verification */
export interface VerificationResult {
/** Whether all checks passed */
passed: boolean;
/** Individual verification checks */
checks: VerificationCheck[];
/** Human-readable summary */
summary: string;
/** Suggestions for replanning if verification failed */
suggestions: string[];
}
/** A single verification check result */
export interface VerificationCheck {
/** Type of check performed */
type: 'test_command' | 'ai_review' | 'file_check';
/** What was checked */
description: string;
/** Whether this check passed */
passed: boolean;
/** Command output or review text */
output?: string;
}
// ═══════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════
/** User-configurable orchestrator options */
export interface OrchestratorConfig {
/** Model to use for plan generation (default: 'opus') */
plannerModel: string;
/** Whether to run research agent before planning (default: true) */
researchEnabled: boolean;
/** Auto-approve generated plans without user review (default: false) */
autoApprove: boolean;
/** Maximum retry attempts per phase (default: 3) */
maxPhaseRetries: number;
/** Phase execution timeout in ms (default: 1800000 = 30min) */
phaseTimeoutMs: number;
/** Enable Claude Code agent teams for parallel phases (default: true) */
enableTeamAgents: boolean;
/** Maximum parallel sessions for task execution (default: 3) */
maxParallelSessions: number;
/** Verification strictness (default: 'moderate') */
verificationMode: 'strict' | 'moderate' | 'lenient';
/** Run /compact between phases to manage context (default: true) */
compactBetweenPhases: boolean;
}
/** Default orchestrator configuration */
export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
plannerModel: 'opus',
researchEnabled: true,
autoApprove: false,
maxPhaseRetries: 3,
phaseTimeoutMs: 30 * 60 * 1000, // 30 minutes
enableTeamAgents: true,
maxParallelSessions: 3,
verificationMode: 'moderate',
compactBetweenPhases: true,
};
// ═══════════════════════════════════════════════════════════════
// Persistence
// ═══════════════════════════════════════════════════════════════
/** Orchestrator state persisted to ~/.codeman/state.json */
export interface OrchestratorPersistState {
/** Current state machine state */
state: OrchestratorState;
/** Generated plan (null before planning) */
plan: OrchestratorPlan | null;
/** Index of currently executing phase */
currentPhaseIndex: number;
/** When orchestration started */
startedAt: number | null;
/** When orchestration completed */
completedAt: number | null;
/** User configuration */
config: OrchestratorConfig;
/** Execution statistics */
stats: OrchestratorStats;
}
/** Orchestrator execution statistics */
export interface OrchestratorStats {
/** Number of phases completed successfully */
phasesCompleted: number;
/** Number of phases that failed (after all retries) */
phasesFailed: number;
/** Total individual tasks completed */
totalTasksCompleted: number;
/** Total individual tasks failed */
totalTasksFailed: number;
/** Total time spent executing (ms) */
totalDurationMs: number;
/** Number of times replanning was triggered */
replanCount: number;
}
/** Factory function for initial orchestrator stats */
export function createInitialOrchestratorStats(): OrchestratorStats {
return {
phasesCompleted: 0,
phasesFailed: 0,
totalTasksCompleted: 0,
totalTasksFailed: 0,
totalDurationMs: 0,
replanCount: 0,
};
}
/** Factory function for initial orchestrator persist state */
export function createInitialOrchestratorPersistState(
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
): OrchestratorPersistState {
return {
state: 'idle',
plan: null,
currentPhaseIndex: 0,
startedAt: null,
completedAt: null,
config,
stats: createInitialOrchestratorStats(),
};
}
+2 -5
View File
@@ -6,7 +6,7 @@
* Key exports:
* - PlanItem — a single task with priority (P0/P1/P2), TDD phase, dependencies, verification criteria
* - PlanTaskStatus — 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'
* - TddPhase / PlanPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review'
* - TddPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review'
*
* Used by PlanOrchestrator (`src/plan-orchestrator.ts`) and the plan API routes
* (`src/web/routes/plan-routes.ts`). Served at `GET /api/sessions/:id/plan/tasks`.
@@ -21,9 +21,6 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'
/** TDD phase categories */
export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
/** Development phase in TDD cycle (alias for TddPhase) */
export type PlanPhase = TddPhase;
/**
* A single plan item for plan orchestration.
* Moved here from plan-orchestrator.ts to break circular dependency.
@@ -42,7 +39,7 @@ export interface PlanItem {
lastError?: string;
completedAt?: number;
complexity?: 'low' | 'medium' | 'high';
tddPhase?: PlanPhase;
tddPhase?: TddPhase;
pairedWith?: string;
reviewChecklist?: string[];
}
+2
View File
@@ -143,6 +143,8 @@ export interface SessionState {
cliLatestVersion?: string;
/** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
}
/**
+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;
}
+1
View File
@@ -12,3 +12,4 @@ export type { RespawnPort } from './respawn-port.js';
export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
+11
View File
@@ -0,0 +1,11 @@
/**
* @fileoverview Orchestrator port — capabilities for orchestrator loop management.
* Route modules that interact with the orchestrator depend on this port.
*/
import type { OrchestratorLoop } from '../../orchestrator-loop.js';
export interface OrchestratorPort {
readonly orchestratorLoop: OrchestratorLoop | null;
initOrchestratorLoop(): OrchestratorLoop;
}
+1 -1
View File
@@ -7,7 +7,7 @@
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class must be defined)
* @loadorder 8 of 9 — loaded after app.js
* @loadorder 14 of 15 — loaded after ralph-wizard.js
*/
// Codeman — Centralized API fetch helpers for CodemanApp
+525 -9851
View File
File diff suppressed because it is too large Load Diff
+25 -69
View File
@@ -2,21 +2,22 @@
* @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules.
*
* This is the first script loaded in index.html. Every other frontend module depends on the
* globals defined here: timing constants, Z-index layers, DEC 2026 sync markers, respawn
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml, extractSyncSegments,
* globals defined here: timing constants, Z-index layers, respawn
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml,
* getEventCoords, scheduleBackground, urlBase64ToUint8Array).
*
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} extractSyncSegments - DEC 2026 terminal sync marker parser
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
* @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
* @loadorder 1 of 15 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
* → keyboard-accessory.js → input-cjk.js → app.js → terminal-ui.js → respawn-ui.js
* → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js → ralph-wizard.js
* → api-client.js → subagent-windows.js
*/
// Codeman — Shared constants and utility functions for frontend modules
@@ -42,7 +43,7 @@ function urlBase64ToUint8Array(base64String) {
// ═══════════════════════════════════════════════════════════════
// Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 5000;
const DEFAULT_SCROLLBACK = 20000;
// Timing constants
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
@@ -52,8 +53,8 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal data (smaller to avoid WebGL GPU stalls)
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 128 * 1024; // 128KB tail for initial load
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
@@ -79,14 +80,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
@@ -282,6 +277,20 @@ const SSE_EVENTS = {
PLAN_STARTED: 'plan:started',
PLAN_CANCELLED: 'plan:cancelled',
PLAN_COMPLETED: 'plan:completed',
// Orchestrator Loop
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
ORCHESTRATOR_PLAN_PROGRESS: 'orchestrator:planProgress',
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
ORCHESTRATOR_VERIFICATION: 'orchestrator:verification',
ORCHESTRATOR_TASK_ASSIGNED: 'orchestrator:taskAssigned',
ORCHESTRATOR_TASK_COMPLETED: 'orchestrator:taskCompleted',
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
};
// ═══════════════════════════════════════════════════════════════
@@ -303,59 +312,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;
+89 -20
View File
@@ -2,36 +2,41 @@
<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>
<!-- Preload critical resources — lets browser discover these during HTML parse
instead of waiting until <script> tags at bottom-of-body are reached. -->
<link rel="preload" href="vendor/xterm.min.js" as="script">
<link rel="preload" href="constants.js" as="script">
<link rel="preload" href="app.js" as="script">
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
'defer' preserves execution order (xterm loads before fit addon). -->
<script defer src="vendor/xterm.min.js"></script>
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;background:#0a0a0a}
.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}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:#1a1a2e;border-radius:4px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#0d0d0d}
.skeleton-toolbar{height:32px;background:#111;border-top:1px solid #1a1a2e}
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
.app-loaded .loading-skeleton{display:none}
</style>
</head>
@@ -59,6 +64,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>
@@ -129,6 +137,17 @@
</div>
</div>
<!-- Orchestrator Loop Panel -->
<div class="orchestrator-panel" id="orchestratorPanel" style="display: none;">
<div class="orchestrator-header">
<span class="orchestrator-title">Orchestrator</span>
<span class="orchestrator-state-badge" id="orchestratorStateBadge">idle</span>
<div class="orchestrator-actions" id="orchestratorActions"></div>
<button class="orchestrator-close-btn" onclick="app.closeOrchestratorPanel()" title="Close">&times;</button>
</div>
<div class="orchestrator-body" id="orchestratorBody"></div>
</div>
<!-- Ralph / Todo Tracker Panel -->
<div class="ralph-panel collapsed" id="ralphStatePanel" style="display: none;">
<!-- Collapsed Summary Bar -->
@@ -228,7 +247,12 @@
<!-- Main Terminal Area -->
<main class="main">
<div class="terminal-container" id="terminalContainer"></div>
<div class="terminal-wrap">
<div class="terminal-container" id="terminalContainer"></div>
<textarea id="cjkInput" rows="1" placeholder="CJK input (Enter = send, Esc = clear)"
maxlength="65536" aria-label="CJK IME input field"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
@@ -253,6 +277,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 +351,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">
@@ -353,6 +384,11 @@
<span>Agent Teams</span>
</label>
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
<label class="checkbox-inline" style="margin-top: 6px;">
<input type="checkbox" id="caseOpusContext1m" onchange="app.onCaseSettingChanged()">
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window for new sessions</span>
</div>
</div>
<!-- Mobile-only case button + gear -->
@@ -377,6 +413,11 @@
<span>Agent Teams</span>
</label>
<span class="form-hint">Enable Agent Teams for new sessions</span>
<label class="checkbox-inline" style="margin-top: 6px;">
<input type="checkbox" id="caseOpusContext1mMobile" onchange="app.onCaseSettingChangedMobile()">
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window</span>
</div>
</div>
@@ -402,6 +443,8 @@
</div>
<div class="toolbar-right">
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -838,6 +881,16 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item settings-item-multiline" title="Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.">
<div class="settings-item-text">
<span class="settings-item-label">CJK Input</span>
<span class="settings-item-desc">Dedicated IME input field for CJK languages</span>
</div>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsCjkInput">
<span class="slider"></span>
</label>
</div>
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
@@ -1003,6 +1056,14 @@
</label>
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
</div>
<div class="form-row form-row-switch">
<label>1M Opus Context</label>
<label class="switch">
<input type="checkbox" id="appSettingsOpusContext1m">
<span class="slider"></span>
</label>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
</div>
<!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div>
<div class="form-row form-row-switch">
@@ -1674,14 +1735,22 @@
<!-- Lines drawn dynamically -->
</svg>
<script defer src="constants.js?v=0.3.2"></script>
<script defer src="mobile-handlers.js?v=0.3.2"></script>
<script defer src="voice-input.js?v=0.3.2"></script>
<script defer src="notification-manager.js?v=0.3.2"></script>
<script defer src="keyboard-accessory.js?v=0.3.2"></script>
<script defer src="app.js?v=0.3.2"></script>
<script defer src="ralph-wizard.js?v=0.3.2"></script>
<script defer src="api-client.js?v=0.3.2"></script>
<script defer src="subagent-windows.js?v=0.3.2"></script>
<script defer src="constants.js"></script>
<script defer src="mobile-handlers.js"></script>
<script defer src="voice-input.js"></script>
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<script defer src="app.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
</body>
</html>
+256
View File
@@ -0,0 +1,256 @@
/**
* @fileoverview CJK IME input for xterm.js terminal.
*
* Always-visible textarea below the terminal (in index.html).
* The browser handles IME composition natively — we just read
* textarea.value and send it to PTY.
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
* Arrow keys and function keys are forwarded to PTY directly.
*
* ## Android IME challenge
*
* Android virtual keyboards (WeChat, Sogou, Gboard in Chinese mode) use
* composition for EVERYTHING — including English prediction and punctuation.
* This means compositionstart fires even for English text, and compositionend
* may not fire until the user explicitly confirms (space, candidate tap).
*
* We use InputEvent.inputType to distinguish:
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
* - `insertText`: final committed text (confirmed word, punctuation, space)
*
* During composition, `insertText` events are flushed immediately (punctuation,
* English words confirmed by IME). `insertCompositionText` waits for
* compositionend (CJK candidate selection).
*
* ## Phantom character for Android backspace
*
* Android virtual keyboards don't generate key-repeat keydown events for held
* keys. When the textarea is empty, backspace produces no `input` event either
* (nothing to delete). We keep a zero-width space (U+200B) "phantom" in the
* textarea at all times. Backspace deletes the phantom → `input` fires with
* `deleteContentBackward` → we send \x7f to PTY and restore the phantom.
* Long-press backspace generates rapid deleteContentBackward events, each
* handled the same way — giving continuous deletion at the keyboard's native
* repeat rate.
*
* @dependency index.html (#cjkInput textarea)
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
* @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
*/
// eslint-disable-next-line no-unused-vars
const CjkInput = (() => {
let _textarea = null;
let _send = null;
let _initialized = false;
let _composing = false;
const _listeners = {};
// Zero-width space: always present in textarea so Android backspace has
// something to delete, triggering the `input` event we need to detect it.
const PHANTOM = '\u200B';
const PASSTHROUGH_KEYS = {
ArrowUp: '\x1b[A',
ArrowDown: '\x1b[B',
ArrowLeft: '\x1b[D',
ArrowRight: '\x1b[C',
Home: '\x1b[H',
End: '\x1b[F',
Tab: '\t',
};
const CTRL_KEYS = {
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
};
/** Strip phantom characters from a string */
function _strip(str) {
return str.replace(/\u200B/g, '');
}
/** Reset textarea to phantom-only state with cursor at end */
function _resetToPhantom() {
_textarea.value = PHANTOM;
_textarea.setSelectionRange(1, 1);
}
/** Check if textarea contains only phantom(s) or is empty — no real user text */
function _isEffectivelyEmpty() {
return !_strip(_textarea.value);
}
/** Flush textarea: send real text to PTY and reset to phantom */
function _flush() {
const val = _strip(_textarea.value);
if (val) {
_send(val);
}
_resetToPhantom();
}
return {
init({ send }) {
if (_initialized) this.destroy();
_send = send;
_composing = false;
_textarea = document.getElementById('cjkInput');
if (!_textarea) return this;
// Seed the phantom character
_resetToPhantom();
_listeners.mousedown = (e) => { e.stopPropagation(); };
_listeners.focus = () => {
window.cjkActive = true;
// Restore phantom if textarea was emptied while blurred
if (!_textarea.value) _resetToPhantom();
};
_listeners.blur = () => { window.cjkActive = false; };
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('focus', _listeners.focus);
_textarea.addEventListener('blur', _listeners.blur);
// ── Composition tracking ──
_listeners.compositionstart = () => {
_composing = true;
// Clear phantom so IME sees a clean textarea — some IMEs include
// existing text in the composition region which would corrupt input.
if (_textarea.value === PHANTOM) {
_textarea.value = '';
}
};
_listeners.compositionend = () => {
_composing = false;
// Defer flush: some Android IMEs haven't committed text to textarea
// when compositionend fires. setTimeout(0) ensures we read the final value.
setTimeout(_flush, 0);
};
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
_textarea.addEventListener('compositionend', _listeners.compositionend);
// ── Keydown: special keys work REGARDLESS of composition state ──
_listeners.keydown = (e) => {
// Enter: flush accumulated text (or bare Enter if empty).
// No isComposing guard — Android IMEs set isComposing=true for English
// prediction, but Enter should ALWAYS send. We preventDefault to stop
// the IME from also handling Enter (which could double-send or do nothing).
if (e.key === 'Enter') {
e.preventDefault();
_composing = false;
const val = _strip(_textarea.value);
if (val) {
_send(val + '\r');
} else {
_send('\r');
}
_resetToPhantom();
return;
}
// Escape: clear textarea (always works)
if (e.key === 'Escape') {
e.preventDefault();
_composing = false;
_resetToPhantom();
return;
}
// Ctrl combos: forward to PTY (always works)
if (e.ctrlKey && CTRL_KEYS[e.key]) {
e.preventDefault();
_send(CTRL_KEYS[e.key]);
return;
}
// Below: only when NOT composing (composing keystrokes belong to IME)
if (_composing) return;
// Backspace: forward to PTY when no real text in textarea
// (Desktop path — Android uses the input event + phantom approach)
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
e.preventDefault();
_send('\x7f');
_resetToPhantom();
return;
}
// Arrow/function keys: forward to PTY when no real text
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();
_send(PASSTHROUGH_KEYS[e.key]);
return;
}
// Single printable character: send immediately to PTY
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
e.preventDefault();
_send(e.key);
return;
}
};
_textarea.addEventListener('keydown', _listeners.keydown);
// ── Input event: the primary path for Android virtual keyboards ──
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
// making keydown unreliable. input fires AFTER character insertion and
// carries inputType which tells us whether the text is final or tentative.
_listeners.input = (e) => {
// ── Backspace / delete detection ──
// Android long-press backspace generates rapid deleteContentBackward events.
// The phantom character ensures the textarea is never truly empty, so each
// press/repeat fires an input event that we can catch here.
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
if (_isEffectivelyEmpty()) {
// No real text left — forward backspace to PTY
_send('\x7f');
_resetToPhantom();
return;
}
// User is editing their own text in the textarea — let it be.
// Ensure phantom is still present for the NEXT backspace.
if (!_textarea.value.startsWith(PHANTOM)) {
_textarea.value = PHANTOM + _textarea.value;
_textarea.setSelectionRange(1, 1);
}
return;
}
if (_composing) {
// insertText during composition = IME committed final text
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
// Flush immediately — this text won't change.
if (e.inputType === 'insertText') {
_flush();
return;
}
// insertCompositionText = IME is still working (pinyin, candidates,
// English prediction). Wait for compositionend to flush.
return;
}
// Outside composition: send immediately
_flush();
};
_textarea.addEventListener('input', _listeners.input);
_initialized = true;
return this;
},
destroy() {
if (_textarea) {
for (const [event, handler] of Object.entries(_listeners)) {
if (handler) _textarea.removeEventListener(event, handler);
}
}
window.cjkActive = false;
_composing = false;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
get element() { return _textarea; },
};
})();
+1 -1
View File
@@ -18,7 +18,7 @@
*
* @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
* @loadorder 5 of 15 — loaded after notification-manager.js, before app.js
*/
// Codeman — Keyboard accessory bar and focus trap for modals
+134 -44
View File
@@ -19,7 +19,7 @@
* @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
* @loadorder 2 of 15 — loaded after constants.js, before voice-input.js
*/
// Codeman — Mobile detection, keyboard handling, and swipe navigation
@@ -36,15 +36,19 @@
const MobileDetection = {
/** Check if device supports touch input */
isTouchDevice() {
return 'ontouchstart' in window ||
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
);
},
/** Check if device is iOS (iPhone, iPad, iPod) */
isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
},
/** Check if browser is Safari */
@@ -77,7 +81,14 @@ const MobileDetection = {
const isTouch = this.isTouchDevice();
// Remove existing device classes
body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser');
body.classList.remove(
'device-mobile',
'device-tablet',
'device-desktop',
'touch-device',
'ios-device',
'safari-browser'
);
// Add current device class
body.classList.add(`device-${deviceType}`);
@@ -98,14 +109,37 @@ 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.
* Skips when virtual keyboard is open — KeyboardHandler manages
* layout via translateY + paddingBottom; shrinking --app-height
* would double-count and leave zero space for the terminal. */
updateAppHeight() {
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
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);
@@ -130,7 +164,7 @@ const MobileDetection = {
this._gestureStartHandler = null;
this._gestureChangeHandler = null;
}
}
},
};
// ═══════════════════════════════════════════════════════════════
@@ -179,6 +213,17 @@ const KeyboardHandler = {
// Also handle scroll (iOS scrolls viewport when keyboard appears)
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
}
// Prevent page-level scroll when keyboard is visible.
// iOS Safari scrolls the document to bring xterm's hidden textarea into
// view when the user types, pushing the entire UI off-screen. The CSS
// position:fixed on .app prevents most cases, but reset as a safety net.
this._windowScrollHandler = () => {
if (this.keyboardVisible) {
window.scrollTo(0, 0);
}
};
window.addEventListener('scroll', this._windowScrollHandler);
},
/** Remove event listeners */
@@ -195,6 +240,10 @@ const KeyboardHandler = {
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
this._viewportScrollHandler = null;
}
if (this._windowScrollHandler) {
window.removeEventListener('scroll', this._windowScrollHandler);
this._windowScrollHandler = null;
}
},
/** Handle viewport resize (keyboard show/hide) */
@@ -206,6 +255,10 @@ const KeyboardHandler = {
if (heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
document.body.classList.add('keyboard-visible');
// Restore --app-height: MobileDetection's resize listener fires before ours
// and may have already shrunk it for the keyboard viewport change.
// Use initialViewportHeight (captured before keyboard opened).
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
this.onKeyboardShow();
}
// Keyboard hidden (viewport grew back close to initial)
@@ -215,6 +268,9 @@ const KeyboardHandler = {
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
// updates while keyboardVisible was true)
MobileDetection.updateAppHeight();
}
// Update baseline when keyboard is not visible — adapts to address bar
@@ -242,35 +298,32 @@ const KeyboardHandler = {
const main = document.querySelector('.main');
if (this.keyboardVisible) {
// Calculate keyboard offset
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = layoutHeight - visualBottom;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
// Safety: if keyboard is supposedly visible but offset is 0 or negative,
// the keyboard is actually gone — force dismiss. This catches cases where
// visualViewport.resize fires late or with intermediate values on iOS.
if (keyboardOffset <= 0) {
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
return;
}
// Move toolbar up above keyboard
// Move toolbar and accessory bar above keyboard.
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
// the bars are naturally positioned via their CSS bottom values —
// just clear the transforms. Never dismiss keyboard state here;
// that's handleViewportResize's job.
if (toolbar) {
toolbar.style.transform = `translateY(${-keyboardOffset}px)`;
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
// Move accessory bar up (it sits above toolbar)
if (accessoryBar) {
accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`;
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
// Shrink main content area so terminal doesn't extend behind keyboard
// Account for keyboard height + toolbar height (40px) + accessory bar (44px)
if (main) {
main.style.paddingBottom = `${keyboardOffset + 94}px`;
// Shrink main content area so terminal doesn't extend behind keyboard.
// Use stable keyboard height (not scroll-dependent) for padding.
// 84px = toolbar (40px) + accessory bar (44px).
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
if (main && keyboardHeight > 0) {
main.style.paddingBottom = `${keyboardHeight + 84}px`;
}
} else {
this.resetLayout();
@@ -301,6 +354,10 @@ const KeyboardHandler = {
KeyboardAccessoryBar.show();
}
// Reset any page scroll that occurred during keyboard open.
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
window.scrollTo(0, 0);
// Refit terminal locally AND send resize to server so Claude Code (Ink)
// knows the actual terminal dimensions. Without this, Ink redraws at the
// old (larger) row count when the user types, causing content to scroll
@@ -309,11 +366,21 @@ const KeyboardHandler = {
// while keyboard is up — this one-shot resize on open/close is sufficient.
setTimeout(() => {
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) try { app.fitAddon.fit(); } catch {}
if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
// Eliminate terminal row quantization gap: xterm can only show whole
// rows, so leftover pixels create dead space below the last row.
// Shrink .main's paddingBottom by the gap so the terminal fills flush
// to the accessory bar.
this._shrinkPaddingToFit();
app.terminal.scrollToBottom();
// Send resize to server so PTY dimensions match xterm
this._sendTerminalResize();
}
// Reset again after fit/resize in case layout changes triggered scroll
window.scrollTo(0, 0);
}, 150);
// Reposition subagent windows to stack from bottom (above keyboard)
@@ -332,7 +399,9 @@ const KeyboardHandler = {
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
setTimeout(() => {
if (typeof app !== 'undefined' && app.fitAddon) {
try { app.fitAddon.fit(); } catch {}
try {
app.fitAddon.fit();
} catch {}
if (app.terminal) app.terminal.scrollToBottom();
// Send resize to server to restore full terminal size
this._sendTerminalResize();
@@ -355,12 +424,37 @@ const KeyboardHandler = {
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
body: JSON.stringify({ cols, rows }),
}).catch(() => {});
}
} catch {}
},
/**
* Shrink .main paddingBottom to eliminate the terminal row quantization gap.
* xterm can only render whole rows, so fractional-row pixels create dead
* space below the last row. After fitAddon.fit(), measure the gap and
* reduce padding by that amount so the terminal sits flush against the bars.
*/
_shrinkPaddingToFit() {
try {
const container = document.getElementById('terminalContainer');
const main = document.querySelector('.main');
if (!container || !main || typeof app === 'undefined' || !app.terminal) return;
const cellH = app.terminal._core?._renderService?.dimensions?.css?.cell?.height;
if (!cellH) return;
const gap = container.clientHeight - app.terminal.rows * cellH;
if (gap > 0 && gap < cellH) {
const currentPadding = parseInt(main.style.paddingBottom) || 0;
main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px';
if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
}
} catch {}
},
/** Check if element is an input that triggers keyboard (excludes terminal) */
isInputElement(el) {
if (!el) return false;
@@ -378,11 +472,7 @@ const KeyboardHandler = {
return false;
}
}
return (
tagName === 'input' ||
tagName === 'textarea' ||
el.isContentEditable
);
return tagName === 'input' || tagName === 'textarea' || el.isContentEditable;
},
/** Scroll input into view above the keyboard */
@@ -408,7 +498,7 @@ const KeyboardHandler = {
// For page-level - use scrollIntoView
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
},
};
// ═══════════════════════════════════════════════════════════════
@@ -423,8 +513,8 @@ const SwipeHandler = {
startX: 0,
startY: 0,
startTime: 0,
minSwipeDistance: 80, // Minimum pixels for a valid swipe
maxSwipeTime: 300, // Maximum ms for a swipe gesture
minSwipeDistance: 80, // Minimum pixels for a valid swipe
maxSwipeTime: 300, // Maximum ms for a swipe gesture
maxVerticalDrift: 100, // Max vertical movement allowed
_touchStartHandler: null,
@@ -475,9 +565,9 @@ const SwipeHandler = {
const deltaX = endX - this.startX;
const deltaY = Math.abs(endY - this.startY);
if (elapsed > this.maxSwipeTime) return; // Too slow
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
if (elapsed > this.maxSwipeTime) return; // Too slow
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
// Valid swipe detected
if (deltaX > 0) {
@@ -487,5 +577,5 @@ const SwipeHandler = {
// Swipe left -> next session
if (typeof app !== 'undefined') app.nextSession();
}
}
},
};
+316 -81
View File
@@ -65,13 +65,14 @@ html.mobile-init .file-browser-panel {
max-height: calc(48px + var(--safe-area-top));
}
/* Add top margin to main content to account for fixed header */
.main {
margin-top: 54px;
/* Push ALL content below fixed header (not just .main) so banners
(respawn, timer, orchestrator) between header and main are visible */
.app {
padding-top: 54px;
}
.ios-device .main {
margin-top: calc(54px + var(--safe-area-top));
.ios-device .app {
padding-top: calc(54px + var(--safe-area-top));
}
/* Font controls - smaller on tablet, visibility controlled by JS */
@@ -234,6 +235,66 @@ html.mobile-init .file-browser-panel {
.subagent-window-body {
font-size: 0.7rem;
}
/* ---- Respawn Banner: Tablet Optimizations ---- */
.respawn-banner {
padding: 0.25rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.65rem;
}
.respawn-compact-layout {
gap: 0.75rem;
}
.respawn-status-row1 {
gap: 0.4rem;
}
.respawn-action-log {
max-height: 2.6em;
font-size: 0.6rem;
}
.respawn-countdown-timer {
font-size: 0.55rem;
}
/* Respawn timer bar — slimmer on tablet */
.respawn-timer-bar {
width: 24px;
}
/* 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;
}
}
/* ============================================================================
@@ -298,13 +359,29 @@ html.mobile-init .file-browser-panel {
max-height: calc(36px + var(--safe-area-top));
}
/* Add top margin to main content to account for fixed header */
.main {
margin-top: 42px;
/* Push ALL content below fixed header (not just .main) so banners
(respawn, timer, orchestrator) between header and main are visible.
Bottom padding for the fixed toolbar (40px) so terminal content
doesn't extend behind it. JS overrides paddingBottom on .main
when keyboard is visible, and resetLayout() clears the inline
style to re-expose this CSS value. */
.app {
padding-top: 42px;
}
.ios-device .main {
margin-top: calc(42px + var(--safe-area-top));
.ios-device .app {
padding-top: calc(42px + var(--safe-area-top));
}
.main {
padding-bottom: calc(40px + var(--safe-area-bottom));
}
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
browser's bottom bar. Match that offset in main's padding so the terminal
doesn't extend behind the toolbar. */
.ios-device.safari-browser .main {
padding-bottom: calc(40px + var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
}
.header-right {
@@ -399,6 +476,18 @@ html.mobile-init .file-browser-panel {
position: relative;
}
/* When keyboard is visible, pin the app container to the viewport.
iOS Safari scrolls the page to bring the focused input (xterm's hidden
textarea) into view, even with overflow:hidden. position:fixed prevents
the browser from scrolling the document under the app. */
.keyboard-visible .app {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
.session-tabs,
@@ -498,6 +587,24 @@ 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)));
}
/* When keyboard is visible the JS translateY already accounts for the full
distance from the visual-viewport bottom to the layout-viewport bottom
(keyboard + Safari bar). Remove the CSS Safari-bar offset to avoid
double-counting, which otherwise creates a visible gap above the keyboard. */
.keyboard-visible.ios-device.safari-browser .toolbar {
bottom: var(--safe-area-bottom);
}
.keyboard-visible.ios-device.safari-browser .keyboard-accessory-bar {
bottom: calc(var(--safe-area-bottom) + 40px);
}
/* Show case selector in center */
.toolbar-center {
display: flex !important;
@@ -626,12 +733,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 */
@@ -1049,40 +1164,26 @@ html.mobile-init .file-browser-panel {
display: block;
}
/* Mobile terminal - native touch scrolling via xterm-viewport */
/* Mobile terminal — JS touch handler (terminal.scrollLines()) drives
scrollback because xterm.js DOM renderer doesn't populate xterm-viewport's
scroll area. touch-action:none lets our JS handler own the gesture. */
.terminal-container {
height: 100%;
min-height: 0;
position: relative;
overflow: visible; /* Must not be hidden — blocks touch scroll on viewport */
touch-action: pan-y;
}
.terminal-container .xterm {
touch-action: pan-y;
}
/* xterm-viewport is the scrollable element inside xterm.js.
It must sit above xterm-screen to receive touch events.
See: https://github.com/xtermjs/xterm.js/issues/5377 */
.terminal-container .xterm-viewport {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 10 !important;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overflow-y: scroll !important;
overflow: visible;
touch-action: none;
}
.terminal-container .xterm,
.terminal-container .xterm-viewport,
.terminal-container .xterm-screen {
touch-action: pan-y;
touch-action: none;
}
/* Compact welcome overlay for mobile */
.welcome-content {
max-width: calc(100vw - 1.5rem);
padding: 1rem 0.75rem;
}
@@ -1148,31 +1249,70 @@ html.mobile-init .file-browser-panel {
/* ---- Respawn Banner: Mobile Optimizations ---- */
.respawn-banner {
padding: 0.4rem 0.5rem;
padding: 0.3rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem;
font-size: 0.65rem;
}
/* Stack banner vertically on mobile — status on top, action log below */
.respawn-compact-layout {
flex-direction: column;
gap: 0.25rem;
gap: 0.15rem;
}
/* Let status column shrink to fit */
.respawn-status-col {
flex-shrink: 1;
min-width: 0;
gap: 0.1rem;
}
/* Wrap status row items — tokens/timer may go to second line */
/* Row1: wrap allowed, key info prioritized via order */
.respawn-status-row1 {
flex-wrap: wrap;
gap: 0.35rem;
gap: 0.25rem;
row-gap: 0.1rem;
}
/* Stop button — 44px touch target */
/* State label — smaller on phone, stays first */
.respawn-state {
font-size: 0.6rem;
padding: 0.05rem 0.3rem;
}
/* Confidence badge — compact */
.respawn-banner .detection-confidence {
font-size: 0.5rem;
padding: 0.02rem 0.2rem;
}
/* Cycles — compact */
.respawn-cycles {
font-size: 0.55rem;
}
/* Run timer — compact */
.respawn-timer {
font-size: 0.55rem;
padding: 0 0.25rem;
}
/* Tokens — compact, truncate if long */
.respawn-tokens {
font-size: 0.55rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* Spinner icon — smaller on phone */
.respawn-indicator {
font-size: 0.65rem;
}
/* Stop button — 36px touch target, pushed right */
.respawn-banner .btn-icon-only {
min-width: 36px;
min-height: 36px;
@@ -1182,26 +1322,50 @@ html.mobile-init .file-browser-panel {
justify-content: center;
margin-left: auto;
border-radius: 6px;
flex-shrink: 0;
}
/* Action log — horizontal strip below status, border on top instead of left */
.respawn-action-log {
border-left: none;
border-top: 1px solid rgba(34, 197, 94, 0.2);
padding-left: 0;
padding-top: 0.25rem;
max-height: 2em;
font-size: 0.6rem;
/* Row2: tighter spacing, smaller text */
.respawn-status-row2 {
font-size: 0.55rem;
gap: 0.3rem;
min-height: 0;
flex-wrap: wrap;
}
/* Countdown timers — tighter on mobile */
/* Detection badges — smaller on phone */
.respawn-banner .detection-hook,
.respawn-banner .detection-ai-check,
.respawn-banner .detection-status {
font-size: 0.55rem;
padding: 0.02rem 0.2rem;
}
/* Countdown timers — inline, no progress bars */
.respawn-countdown-timers {
gap: 0.2rem;
}
.respawn-countdown-timer {
font-size: 0.5rem;
padding: 0.02rem 0.2rem;
gap: 0.15rem;
}
/* Hide progress bars on phone — values are enough */
.respawn-countdown-timer .respawn-timer-bar {
display: none;
}
/* Action log — single-line strip below status, show latest entry only */
.respawn-action-log {
border-left: none;
border-top: 1px solid rgba(34, 197, 94, 0.15);
padding-left: 0;
padding-top: 0.15rem;
max-height: 1.4em;
font-size: 0.55rem;
padding: 0.05rem 0.25rem;
overflow: hidden;
}
/* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */
@@ -1227,28 +1391,27 @@ html.mobile-init .file-browser-panel {
border-radius: 5px;
}
/* Duration preset buttons — single row, all 7 items */
/* Duration preset buttons — grid layout, 4 columns for even spacing */
.duration-presets {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.2rem;
}
.duration-preset-btn {
min-height: 28px;
padding: 0.2rem 0.4rem;
min-height: 32px;
padding: 0.2rem 0.25rem;
font-size: 0.65rem;
border-radius: 5px;
text-align: center;
flex: 1 1 auto;
min-width: 0;
}
/* Custom duration — inline with other presets */
/* Custom duration — spans full row below */
.duration-custom {
flex-wrap: wrap;
grid-column: 1 / -1;
display: flex;
gap: 0.25rem;
flex: 1 1 100%;
align-items: center;
}
.duration-custom .duration-preset-btn {
@@ -1266,24 +1429,23 @@ html.mobile-init .file-browser-panel {
font-size: 16px; /* Prevents iOS zoom */
}
/* Preset selector row — compact inline */
/* Preset selector row — full-width dropdown, buttons below */
.preset-selector {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem;
}
.preset-selector select {
width: 100%;
min-height: 28px;
grid-column: 1 / -1;
min-height: 32px;
font-size: 16px; /* Prevents iOS zoom */
border-radius: 5px;
padding: 0.2rem 0.4rem;
}
.preset-selector .btn {
flex: 1;
min-height: 28px;
min-height: 32px;
font-size: 0.65rem;
border-radius: 5px;
padding: 0.2rem 0.4rem;
@@ -1519,11 +1681,9 @@ html.mobile-init .file-browser-panel {
scroll-margin-top: 80px;
}
/* When keyboard is visible, adjust content to account for moved toolbar */
.keyboard-visible .terminal-container {
/* Reduce terminal height when keyboard is up so it doesn't overlap toolbar */
padding-bottom: 50px;
}
/* When keyboard is visible, the JS paddingBottom on .main already reserves
space for toolbar + accessory bar. No extra padding needed here — it was
double-counting and creating dead space between terminal and the bars. */
/* Ensure modals scroll properly when keyboard is visible */
.keyboard-visible .modal-body {
@@ -1895,6 +2055,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
============================================================================ */
@@ -1903,14 +2144,8 @@ html.mobile-init .file-browser-panel {
overscroll-behavior: none;
}
.ios-device.safari-browser .terminal-container {
/* Keep momentum scrolling but prevent page bounce */
-webkit-overflow-scrolling: touch;
}
.ios-device.safari-browser .terminal-container .xterm-viewport {
-webkit-overflow-scrolling: touch;
}
/* JS touch handler now drives terminal scrollback — no native scroll needed.
-webkit-overflow-scrolling and overflow-y:scroll on xterm-viewport removed. */
/* ============================================================================
Hover State Fallbacks for Touch
+1 -1
View File
@@ -22,7 +22,7 @@
*
* @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
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
*/
// Codeman — Multi-layer notification system
+475
View File
@@ -0,0 +1,475 @@
/**
* @fileoverview Orchestrator loop panel — plan-based autonomous execution UI.
* Shows orchestrator state, plan phases, task progress, and verification results.
* Provides controls for start, approve, reject, pause, resume, stop, skip, retry.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.orchestratorState)
* @dependency constants.js (SSE_EVENTS, escapeHtml)
* @loadorder 9.5 of 16 — loaded after ralph-panel.js, before settings-ui.js
*/
// ═══════════════════════════════════════════════════════════════
// State color/label mappings
// ═══════════════════════════════════════════════════════════════
const ORCH_STATE_COLORS = {
idle: '#6b7280',
planning: '#f59e0b',
approval: '#8b5cf6',
executing: '#3b82f6',
verifying: '#06b6d4',
replanning: '#f97316',
completed: '#22c55e',
failed: '#ef4444',
paused: '#9ca3af',
};
const ORCH_PHASE_STATUS_ICONS = {
pending: '\u25cb', // ○
executing: '\u25d4', // ◔
passed: '\u2713', // ✓
failed: '\u2717', // ✗
skipped: '\u2192', // →
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// SSE Event Handlers
// ═══════════════════════════════════════════════════════════════
_onOrchestratorStateChanged(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.state = data.state;
if (data.state === 'planning') {
this.orchestratorState.planProgress = [];
this.showOrchestratorPanel();
}
this.renderOrchestratorPanel();
},
_onOrchestratorPlanProgress(data) {
if (!this.orchestratorState) this.orchestratorState = {};
if (!this.orchestratorState.planProgress) this.orchestratorState.planProgress = [];
this.orchestratorState.planProgress.push({ phase: data.phase, detail: data.detail, time: Date.now() });
this.renderOrchestratorPanel();
},
_onOrchestratorPlanReady(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.plan = data.plan;
this.orchestratorState.state = 'approval';
this.showOrchestratorPanel();
this.renderOrchestratorPanel();
},
_onOrchestratorPhaseStarted(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorPhase(data.phase);
this.renderOrchestratorPanel();
},
_onOrchestratorPhaseCompleted(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorPhase(data.phase);
this.renderOrchestratorPanel();
},
_onOrchestratorPhaseFailed(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorPhase(data.phase);
this.renderOrchestratorPanel();
},
_onOrchestratorVerification(data) {
if (!this.orchestratorState) return;
// Store verification result on the phase
if (this.orchestratorState.plan) {
const phase = this.orchestratorState.plan.phases.find(p => p.id === data.phaseId);
if (phase) phase._lastVerification = data.result;
}
this.renderOrchestratorPanel();
},
_onOrchestratorTaskAssigned(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorTask(data.task);
this.renderOrchestratorPanel();
},
_onOrchestratorTaskCompleted(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorTask(data.task);
this.renderOrchestratorPanel();
},
_onOrchestratorTaskFailed(data) {
if (!this.orchestratorState) return;
this._updateOrchestratorTask(data.task);
this.renderOrchestratorPanel();
},
_onOrchestratorCompleted(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.state = 'completed';
this.orchestratorState.stats = data.stats;
this.renderOrchestratorPanel();
},
_onOrchestratorError(data) {
if (!this.orchestratorState) this.orchestratorState = {};
this.orchestratorState.state = 'failed';
this.orchestratorState.lastError = data.error;
this.renderOrchestratorPanel();
},
// ═══════════════════════════════════════════════════════════════
// Internal helpers
// ═══════════════════════════════════════════════════════════════
_updateOrchestratorPhase(updatedPhase) {
if (!this.orchestratorState?.plan) return;
const idx = this.orchestratorState.plan.phases.findIndex(p => p.id === updatedPhase.id);
if (idx >= 0) this.orchestratorState.plan.phases[idx] = updatedPhase;
},
_updateOrchestratorTask(updatedTask) {
if (!this.orchestratorState?.plan) return;
for (const phase of this.orchestratorState.plan.phases) {
const idx = phase.tasks.findIndex(t => t.id === updatedTask.id);
if (idx >= 0) {
phase.tasks[idx] = updatedTask;
return;
}
}
},
// ═══════════════════════════════════════════════════════════════
// Panel visibility
// ═══════════════════════════════════════════════════════════════
showOrchestratorPanel() {
this.orchestratorPanelVisible = true;
const panel = document.getElementById('orchestratorPanel');
if (panel) panel.style.display = '';
this.renderOrchestratorPanel();
},
closeOrchestratorPanel() {
this.orchestratorPanelVisible = false;
const panel = document.getElementById('orchestratorPanel');
if (panel) panel.style.display = 'none';
},
toggleOrchestratorPanel() {
if (this.orchestratorPanelVisible) {
this.closeOrchestratorPanel();
} else {
this.showOrchestratorPanel();
}
},
// ═══════════════════════════════════════════════════════════════
// API calls
// ═══════════════════════════════════════════════════════════════
async orchestratorStart(goal, config) {
try {
const res = await fetch('/api/orchestrator/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal, config }),
});
const data = await res.json();
if (data.ok) {
this.orchestratorState = { state: 'planning', plan: null };
this.showOrchestratorPanel();
this.renderOrchestratorPanel();
}
return data;
} catch (err) {
console.error('[Orchestrator] Start failed:', err);
}
},
async orchestratorApprove() {
try {
await fetch('/api/orchestrator/approve', { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Approve failed:', err);
}
},
async orchestratorReject(feedback) {
try {
await fetch('/api/orchestrator/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback }),
});
} catch (err) {
console.error('[Orchestrator] Reject failed:', err);
}
},
async orchestratorPause() {
try {
await fetch('/api/orchestrator/pause', { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Pause failed:', err);
}
},
async orchestratorResume() {
try {
await fetch('/api/orchestrator/resume', { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Resume failed:', err);
}
},
async orchestratorStop() {
try {
await fetch('/api/orchestrator/stop', { method: 'POST' });
this.orchestratorState = { state: 'idle' };
this.renderOrchestratorPanel();
} catch (err) {
console.error('[Orchestrator] Stop failed:', err);
}
},
async orchestratorSkipPhase(phaseId) {
try {
await fetch(`/api/orchestrator/phase/${phaseId}/skip`, { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Skip failed:', err);
}
},
async orchestratorRetryPhase(phaseId) {
try {
await fetch(`/api/orchestrator/phase/${phaseId}/retry`, { method: 'POST' });
} catch (err) {
console.error('[Orchestrator] Retry failed:', err);
}
},
async refreshOrchestratorStatus() {
try {
const res = await fetch('/api/orchestrator/status');
const data = await res.json();
if (data.ok) {
this.orchestratorState = data;
this.renderOrchestratorPanel();
}
} catch (err) {
console.error('[Orchestrator] Status fetch failed:', err);
}
},
// ═══════════════════════════════════════════════════════════════
// Rendering
// ═══════════════════════════════════════════════════════════════
renderOrchestratorPanel() {
const panel = document.getElementById('orchestratorPanel');
if (!panel) return;
const state = this.orchestratorState?.state || 'idle';
const plan = this.orchestratorState?.plan;
// Update badge
const badge = document.getElementById('orchestratorStateBadge');
if (badge) {
badge.textContent = state;
badge.style.background = ORCH_STATE_COLORS[state] || '#6b7280';
}
// Update action buttons
const actions = document.getElementById('orchestratorActions');
if (actions) {
actions.innerHTML = this._renderOrchestratorActions(state);
}
// Update body
const body = document.getElementById('orchestratorBody');
if (body) {
body.innerHTML = this._renderOrchestratorBody(state, plan);
}
},
_renderOrchestratorActions(state) {
const btn = (label, onclick, cls = '') =>
`<button class="orch-btn ${cls}" onclick="${onclick}">${label}</button>`;
switch (state) {
case 'idle':
case 'completed':
case 'failed':
return btn('New Goal', 'app.promptOrchestratorGoal()', 'orch-btn-primary');
case 'planning':
return btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger');
case 'approval':
return [
btn('Approve', 'app.orchestratorApprove()', 'orch-btn-primary'),
btn('Reject', 'app.promptOrchestratorReject()', 'orch-btn-warn'),
btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger'),
].join('');
case 'executing':
case 'verifying':
case 'replanning':
return [
btn('Pause', 'app.orchestratorPause()'),
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
].join('');
case 'paused':
return [
btn('Resume', 'app.orchestratorResume()', 'orch-btn-primary'),
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
].join('');
default:
return '';
}
},
_renderOrchestratorBody(state, plan) {
if (state === 'idle' && !plan) {
return '<div class="orch-empty">No orchestration active. Click "New Goal" to start.</div>';
}
if (state === 'planning') {
const progress = this.orchestratorState?.planProgress || [];
let progressHtml = '';
if (progress.length > 0) {
const items = progress.map(p =>
`<div class="orch-progress-item"><span class="orch-progress-phase">${escapeHtml(p.phase)}</span> ${escapeHtml(p.detail)}</div>`
).join('');
progressHtml = `<div class="orch-progress-log">${items}</div>`;
}
return `<div class="orch-planning"><div class="orch-spinner"></div>Generating plan...${progressHtml}</div>`;
}
if (!plan) return '';
const parts = [];
// Goal
parts.push(`<div class="orch-goal"><strong>Goal:</strong> ${escapeHtml(plan.goal.slice(0, 200))}</div>`);
// Progress summary
const completed = plan.phases.filter(p => p.status === 'passed' || p.status === 'skipped').length;
const total = plan.phases.length;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
parts.push(`<div class="orch-progress-bar"><div class="orch-progress-fill" style="width:${pct}%"></div><span>${completed}/${total} phases</span></div>`);
// Phase list
parts.push('<div class="orch-phases">');
for (const phase of plan.phases) {
parts.push(this._renderOrchestratorPhase(phase, state));
}
parts.push('</div>');
// Stats (if completed/failed)
if (state === 'completed' || state === 'failed') {
const stats = this.orchestratorState?.stats;
if (stats) {
parts.push(this._renderOrchestratorStats(stats));
}
if (this.orchestratorState?.lastError) {
parts.push(`<div class="orch-error">Error: ${escapeHtml(this.orchestratorState.lastError)}</div>`);
}
}
return parts.join('');
},
_renderOrchestratorPhase(phase, orchState) {
const icon = ORCH_PHASE_STATUS_ICONS[phase.status] || '\u25cb';
const isActive = phase.status === 'executing';
const cls = `orch-phase ${isActive ? 'orch-phase-active' : ''} orch-phase-${phase.status}`;
let actions = '';
if (orchState === 'executing' || orchState === 'failed') {
if (phase.status === 'pending') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
}
if (phase.status === 'failed') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
}
}
// Task summary
const tasksDone = phase.tasks.filter(t => t.status === 'completed').length;
const tasksFailed = phase.tasks.filter(t => t.status === 'failed').length;
const tasksTotal = phase.tasks.length;
const taskSummary = `${tasksDone}/${tasksTotal}${tasksFailed > 0 ? ` (${tasksFailed} failed)` : ''}`;
// Duration
let duration = '';
if (phase.durationMs) {
const secs = Math.round(phase.durationMs / 1000);
duration = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
}
let html = `<div class="${cls}">`;
html += `<div class="orch-phase-header">`;
html += `<span class="orch-phase-icon">${icon}</span>`;
html += `<span class="orch-phase-name">${escapeHtml(phase.name)}</span>`;
html += `<span class="orch-phase-tasks">${taskSummary}</span>`;
if (duration) html += `<span class="orch-phase-duration">${duration}</span>`;
if (actions) html += `<span class="orch-phase-actions">${actions}</span>`;
html += `</div>`;
// Expanded task list for active phase
if (isActive || phase.status === 'failed') {
html += '<div class="orch-phase-tasks-list">';
for (const task of phase.tasks) {
const taskIcon = ORCH_PHASE_STATUS_ICONS[task.status] || '\u25cb';
const taskCls = `orch-task orch-task-${task.status}`;
html += `<div class="${taskCls}"><span class="orch-task-icon">${taskIcon}</span>`;
html += `<span class="orch-task-prompt">${escapeHtml(task.prompt.slice(0, 100))}</span>`;
if (task.error) html += `<span class="orch-task-error">${escapeHtml(task.error.slice(0, 80))}</span>`;
html += '</div>';
}
html += '</div>';
}
// Verification result
if (phase._lastVerification) {
const v = phase._lastVerification;
const vCls = v.passed ? 'orch-verify-pass' : 'orch-verify-fail';
html += `<div class="${vCls}">${v.passed ? 'Verified' : 'Failed'}: ${escapeHtml(v.summary || '')}</div>`;
}
html += '</div>';
return html;
},
_renderOrchestratorStats(stats) {
return `<div class="orch-stats">
<span>Phases: ${stats.phasesCompleted} done, ${stats.phasesFailed} failed</span>
<span>Tasks: ${stats.totalTasksCompleted} done, ${stats.totalTasksFailed} failed</span>
${stats.replanCount > 0 ? `<span>Replans: ${stats.replanCount}</span>` : ''}
${stats.totalDurationMs ? `<span>Duration: ${Math.round(stats.totalDurationMs / 60000)}m</span>` : ''}
</div>`;
},
// ═══════════════════════════════════════════════════════════════
// User prompts
// ═══════════════════════════════════════════════════════════════
promptOrchestratorGoal() {
const goal = prompt('Enter your goal for the orchestrator:');
if (goal && goal.trim()) {
this.orchestratorStart(goal.trim());
}
},
promptOrchestratorReject() {
const feedback = prompt('Feedback on the plan (what should change?):');
if (feedback && feedback.trim()) {
this.orchestratorReject(feedback.trim());
}
},
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@
* @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
* @loadorder 13 of 15 — loaded after session-ui.js, before api-client.js
*/
// ═══════════════════════════════════════════════════════════════
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+982 -226
View File
File diff suppressed because it is too large Load Diff
+120 -20
View File
@@ -15,7 +15,7 @@
* @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
* @loadorder 15 of 15 — loaded last, after api-client.js
*/
// Codeman — Subagent window management for CodemanApp
@@ -121,7 +121,7 @@ Object.assign(CodemanApp.prototype, {
if (!windowData.minimized) {
openWindows.push({
agentId,
position: windowData.position || null
position: windowData.position || null,
});
}
}
@@ -163,8 +163,7 @@ Object.assign(CodemanApp.prototype, {
// Use the PERSISTENT parent map (THE source of truth)
// Fall back to saved sessionId only if it exists in current sessions
const parentFromMap = this.subagentParentMap.get(agentId);
const correctSessionId = parentFromMap ||
(this.sessions.has(savedSessionId) ? savedSessionId : null);
const correctSessionId = parentFromMap || (this.sessions.has(savedSessionId) ? savedSessionId : null);
if (correctSessionId) {
// Ensure the parent map has this association
@@ -184,7 +183,7 @@ Object.assign(CodemanApp.prototype, {
// Restore open windows (for recent, non-completed agents only)
const now = Date.now();
const maxAgeMs = 10 * 60 * 1000; // 10 minutes - don't restore windows for old agents
for (const { agentId, position } of (states.open || [])) {
for (const { agentId, position } of states.open || []) {
const agent = this.subagents.get(agentId);
// Only restore window if agent exists, is recent, and is still active/idle
const agentAge = agent?.startedAt ? now - agent.startedAt : Infinity;
@@ -458,6 +457,83 @@ Object.assign(CodemanApp.prototype, {
}
},
// ═══════════════════════════════════════════════════════════════
// Lazy Terminal Lifecycle
// ═══════════════════════════════════════════════════════════════
//
// Teammate terminal windows use xterm.js Terminal instances that consume
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
// invisible terminals. To avoid this, we dispose the Terminal when a
// window is minimized and lazily re-create it when restored.
//
// Flow:
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
//
// The tmux pane buffer is re-fetched from the API on restore. Regular
// (non-teammate) subagent windows use activity HTML and are unaffected
// by this optimization.
/**
* Dispose a teammate terminal when its window is minimized.
* Saves pane metadata so the terminal can be re-created on restore.
* No-op if the window has no teammate terminal.
*/
_disposeTeammateTerminalForMinimize(agentId) {
const termData = this.teammateTerminals.get(agentId);
if (!termData) return; // Not a teammate terminal window
const windowData = this.subagentWindows.get(agentId);
// Save pane metadata needed to re-create the terminal on restore
if (windowData) {
windowData._lazyTerminal = true;
windowData._lazyPaneTarget = termData.paneTarget;
windowData._lazySessionId = termData.sessionId;
}
// Dispose the resize observer
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
if (termData.terminal) {
try {
termData.terminal.dispose();
} catch {}
}
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
// (the activity HTML can serve as a lightweight placeholder while minimized)
this.teammateTerminals.delete(agentId);
},
/**
* Re-create a teammate terminal when its window is restored from minimized state.
* Fetches the current pane buffer from the API (tmux is the source of truth).
* No-op if the window doesn't have the _lazyTerminal flag.
*/
_restoreTeammateTerminalFromLazy(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData || !windowData._lazyTerminal) return;
const paneTarget = windowData._lazyPaneTarget;
const sessionId = windowData._lazySessionId;
// Clear lazy state
windowData._lazyTerminal = false;
windowData._lazyPaneTarget = null;
windowData._lazySessionId = null;
if (!paneTarget || !sessionId) return;
// Re-create the terminal using the same initTeammateTerminal flow
const paneInfo = { paneTarget, sessionId };
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
},
// ═══════════════════════════════════════════════════════════════
// Subagent Floating Windows
// ═══════════════════════════════════════════════════════════════
@@ -495,7 +571,7 @@ Object.assign(CodemanApp.prototype, {
// Only open windows for agents that belong to a Codeman-managed session tab.
// Agents from external Claude sessions (not tracked by Codeman) should not pop up.
if (agent.sessionId) {
const hasMatchingTab = Array.from(this.sessions.values()).some(s => s.claudeSessionId === agent.sessionId);
const hasMatchingTab = Array.from(this.sessions.values()).some((s) => s.claudeSessionId === agent.sessionId);
if (!hasMatchingTab) return;
}
@@ -600,9 +676,7 @@ Object.assign(CodemanApp.prototype, {
}
// Get parent TAB element for spawn animation
const parentTab = parentSessionId
? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`)
: null;
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Create window element
const win = document.createElement('div');
@@ -611,17 +685,19 @@ Object.assign(CodemanApp.prototype, {
win.style.zIndex = ++this.subagentWindowZIndex;
// Build parent header if we have parent info
const parentHeader = parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
const parentHeader =
parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
</div>`
: '';
: '';
const teammateInfo = this.getTeammateInfo(agent);
const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7));
const windowTitle = teammateInfo ? teammateInfo.name : agent.description || agentId.substring(0, 7);
const maxTitleLen = isMobile ? 30 : 50;
const truncatedTitle = windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
const truncatedTitle =
windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
const modelBadge = agent.modelShort
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
: '';
@@ -695,7 +771,18 @@ Object.assign(CodemanApp.prototype, {
// Render content — check if this teammate has a tmux pane
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
if (paneInfo) {
this.initTeammateTerminal(agentId, paneInfo, win);
if (shouldHide) {
// Window starts hidden — defer terminal creation until visible (lazy init).
// Saves ~75KB of DOM memory per hidden teammate terminal window.
const windowEntry = this.subagentWindows.get(agentId);
if (windowEntry) {
windowEntry._lazyTerminal = true;
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
windowEntry._lazySessionId = paneInfo.sessionId;
}
} else {
this.initTeammateTerminal(agentId, paneInfo, win);
}
} else {
this.renderSubagentWindowContent(agentId);
}
@@ -755,6 +842,10 @@ Object.assign(CodemanApp.prototype, {
this.setAgentParentSessionId(agentId, parentSessionId);
}
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
// The terminal will be lazily re-created on restore via initTeammateTerminal().
this._disposeTeammateTerminalForMinimize(agentId);
// Always minimize to tab
windowData.element.style.display = 'none';
windowData.minimized = true;
@@ -829,7 +920,9 @@ Object.assign(CodemanApp.prototype, {
for (const [, termData] of this.teammateTerminals) {
if (termData.resizeObserver) termData.resizeObserver.disconnect();
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
try {
termData.terminal.dispose();
} catch {}
}
}
this.teammateTerminals.clear();
@@ -898,7 +991,7 @@ Object.assign(CodemanApp.prototype, {
}
// Clean up wizard drag listeners (leak fix: document-level handlers)
this.cleanupWizardDragging();
if (typeof this.cleanupWizardDragging === 'function') this.cleanupWizardDragging();
// Deactivate focus trap if wizard was open (leak fix: keydown listener)
if (this.activeFocusTrap) {
@@ -959,6 +1052,13 @@ Object.assign(CodemanApp.prototype, {
windowData.hidden = false;
}
windowData.minimized = false;
// Lazily re-create teammate terminal if it was disposed on minimize.
// Only re-create when the window is actually becoming visible.
if (shouldShow && windowData._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
this.updateConnectionLines();
// Restack all visible mobile windows so restored ones don't overlap
this.relayoutMobileSubagentWindows();
@@ -1067,7 +1167,7 @@ Object.assign(CodemanApp.prototype, {
if (!dropdown || dropdown.classList.contains('open')) return;
// Close other dropdowns first
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
document.querySelectorAll('.subagent-dropdown.open').forEach((d) => {
d.classList.remove('open', 'pinned');
if (d.parentElement === document.body && d._originalParent) {
d._originalParent.appendChild(d);
@@ -1089,8 +1189,8 @@ Object.assign(CodemanApp.prototype, {
// Schedule hide after delay (allows moving mouse to dropdown)
scheduleHideSubagentDropdown(badgeEl) {
this._subagentHideTimeout = setTimeout(() => {
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
document.querySelector('.subagent-dropdown.open');
const dropdown =
badgeEl?.querySelector?.('.subagent-dropdown') || document.querySelector('.subagent-dropdown.open');
if (dropdown && !dropdown.classList.contains('pinned')) {
dropdown.classList.remove('open');
if (dropdown._originalParent) {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@
*
* @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
* @loadorder 3 of 15 — loaded after mobile-handlers.js, before notification-manager.js
*/
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
+360
View File
@@ -0,0 +1,360 @@
/**
* @fileoverview Respawn event wiring — pure functions that connect RespawnController
* events to SSE broadcasts, push notifications, and run summary tracking.
*
* Extracted from WebServer to keep respawn-specific event plumbing separate from
* HTTP/session concerns. Follows the same DI pattern as session-listener-wiring.ts.
*/
import { Session } from '../session.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import type { PersistedRespawnConfig } from '../types.js';
import type { RunSummaryTracker } from '../run-summary.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import type { TeamWatcher } from '../team-watcher.js';
import { SseEvent } from './sse-events.js';
// ============================================================================
// Dependency Interface
// ============================================================================
export interface RespawnWiringDeps {
broadcast(event: string, data: unknown): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
persistSessionState(session: Session): void;
getSession(sessionId: string): Session | undefined;
sessionExists(sessionId: string): boolean;
getRunSummaryTracker(sessionId: string): RunSummaryTracker | undefined;
getRespawnControllers(): Map<string, RespawnController>;
getRespawnTimers(): Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }>;
getPendingRespawnStarts(): Map<string, NodeJS.Timeout>;
teamWatcher: TeamWatcher;
serverStartTime: number;
respawnRestoreGracePeriodMs: number;
mux: TerminalMultiplexer;
}
// ============================================================================
// Respawn Listener Wiring
// ============================================================================
/**
* Wire a RespawnController's events to SSE broadcasts, push notifications,
* and run summary tracking.
*/
export function wireRespawnListeners(sessionId: string, controller: RespawnController, deps: RespawnWiringDeps): void {
// Wire team watcher for team-aware idle detection
controller.setTeamWatcher(deps.teamWatcher);
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
const getTracker = () => deps.getRunSummaryTracker(sessionId);
// ─── Respawn State Machine ──────────────────────────────
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
deps.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
const tracker = getTracker();
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
});
// ─── Respawn Cycle Lifecycle ────────────────────────────
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
controller.on('respawnCycleStarted', (cycleNumber: number) => {
deps.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
});
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
deps.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
});
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
deps.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
const sessionForPush = deps.getSession(sessionId);
deps.sendPushNotifications(SseEvent.RespawnBlocked, {
sessionId,
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
reason: data.reason,
});
const tracker = getTracker();
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
});
// ─── Respawn Step Progress ──────────────────────────────
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
controller.on('stepSent', (step: string, input: string) => {
deps.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
});
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
controller.on('stepCompleted', (step: string) => {
deps.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
});
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
controller.on('detectionUpdate', (detection: unknown) => {
deps.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
});
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
controller.on('autoAcceptSent', () => {
deps.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
});
// ─── AI Checker Events ──────────────────────────────────
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
controller.on('aiCheckStarted', () => {
deps.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
});
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
deps.broadcast(SseEvent.RespawnAiCheckCompleted, {
sessionId,
verdict: result.verdict,
reasoning: result.reasoning,
durationMs: result.durationMs,
});
const tracker = getTracker();
if (tracker) tracker.recordAiCheckResult(result.verdict);
});
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
controller.on('aiCheckFailed', (error: string) => {
deps.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
const tracker = getTracker();
if (tracker) tracker.recordError('AI check failed', error);
});
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
deps.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
});
// ─── Plan Checker Events ────────────────────────────────
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
controller.on('planCheckStarted', () => {
deps.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
});
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
deps.broadcast(SseEvent.RespawnPlanCheckCompleted, {
sessionId,
verdict: result.verdict,
reasoning: result.reasoning,
durationMs: result.durationMs,
});
});
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
controller.on('planCheckFailed', (error: string) => {
deps.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
});
// ─── Timer Events (UI countdown display) ────────────────
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
controller.on('timerStarted', (timer) => {
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
});
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
controller.on('timerCancelled', (timerName, reason) => {
deps.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
});
/** Broadcasts `respawn:timerCompleted` — timer expired */
controller.on('timerCompleted', (timerName) => {
deps.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
});
// ─── Logging & Errors ───────────────────────────────────
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
controller.on('actionLog', (action) => {
deps.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
});
/** Broadcasts `respawn:log` — general respawn log message */
controller.on('log', (message: string) => {
deps.broadcast(SseEvent.RespawnLog, { sessionId, message });
});
/** Broadcasts `respawn:error` — respawn controller error */
controller.on('error', (error: Error) => {
deps.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
const tracker = getTracker();
if (tracker) tracker.recordError('Respawn error', error.message);
});
}
// ============================================================================
// Timed Respawn
// ============================================================================
/**
* Set up a duration-limited respawn timer that stops respawn after N minutes.
*/
export function setupTimedRespawn(sessionId: string, durationMinutes: number, deps: RespawnWiringDeps): void {
const timers = deps.getRespawnTimers();
// Clear existing timer if any
const existing = timers.get(sessionId);
if (existing) {
clearTimeout(existing.timer);
}
const now = Date.now();
const endAt = now + durationMinutes * 60 * 1000;
const timer = setTimeout(
() => {
// Stop respawn when time is up
const controllers = deps.getRespawnControllers();
const controller = controllers.get(sessionId);
if (controller) {
controller.stop();
controller.removeAllListeners();
controllers.delete(sessionId);
deps.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
}
timers.delete(sessionId);
// Update persisted state (respawn no longer active)
const session = deps.getSession(sessionId);
if (session) {
deps.persistSessionState(session);
}
},
durationMinutes * 60 * 1000
);
timers.set(sessionId, { timer, endAt, startedAt: now });
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
}
// ============================================================================
// Respawn Controller Restore
// ============================================================================
/**
* Restore a RespawnController from persisted configuration.
* Creates the controller, wires listeners, and starts after a grace period.
*/
export function restoreRespawnController(
session: Session,
config: PersistedRespawnConfig,
source: string,
deps: RespawnWiringDeps
): void {
const controller = new RespawnController(session, {
idleTimeoutMs: config.idleTimeoutMs,
updatePrompt: config.updatePrompt,
interStepDelayMs: config.interStepDelayMs,
enabled: true,
sendClear: config.sendClear,
sendInit: config.sendInit,
kickstartPrompt: config.kickstartPrompt,
completionConfirmMs: config.completionConfirmMs,
noOutputTimeoutMs: config.noOutputTimeoutMs,
autoAcceptPrompts: config.autoAcceptPrompts,
autoAcceptDelayMs: config.autoAcceptDelayMs,
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
aiIdleCheckModel: config.aiIdleCheckModel,
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
aiPlanCheckModel: config.aiPlanCheckModel,
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
});
const controllers = deps.getRespawnControllers();
controllers.set(session.id, controller);
wireRespawnListeners(session.id, controller, deps);
// Calculate delay: wait until grace period after server start before starting respawn
// This prevents false idle detection immediately after a server restart/rebuild
const timeSinceStart = Date.now() - deps.serverStartTime;
const delayMs = Math.max(0, deps.respawnRestoreGracePeriodMs - timeSinceStart);
const pendingStarts = deps.getPendingRespawnStarts();
if (delayMs > 0) {
console.log(
`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`
);
const delayTimer = setTimeout(() => {
pendingStarts.delete(session.id);
// Verify session still exists (may have been deleted during grace period)
if (!deps.sessionExists(session.id)) {
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
return;
}
// Double-check controller still exists and is stopped
const ctrl = controllers.get(session.id);
if (ctrl && ctrl.state === 'stopped') {
ctrl.start();
deps.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
}
}, delayMs);
pendingStarts.set(session.id, delayTimer);
} else {
// Grace period has passed, start immediately
controller.start();
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`);
}
if (config.durationMinutes && config.durationMinutes > 0) {
setupTimedRespawn(session.id, config.durationMinutes, deps);
}
}
// ============================================================================
// Respawn Config Persistence
// ============================================================================
/**
* Save respawn config to mux for restart recovery.
*/
export function saveRespawnConfig(
sessionId: string,
config: RespawnConfig,
mux: TerminalMultiplexer,
durationMinutes?: number
): void {
const persistedConfig: PersistedRespawnConfig = {
enabled: config.enabled,
idleTimeoutMs: config.idleTimeoutMs,
updatePrompt: config.updatePrompt,
interStepDelayMs: config.interStepDelayMs,
sendClear: config.sendClear,
sendInit: config.sendInit,
kickstartPrompt: config.kickstartPrompt,
autoAcceptPrompts: config.autoAcceptPrompts,
autoAcceptDelayMs: config.autoAcceptDelayMs,
completionConfirmMs: config.completionConfirmMs,
noOutputTimeoutMs: config.noOutputTimeoutMs,
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
aiIdleCheckModel: config.aiIdleCheckModel,
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
aiPlanCheckModel: config.aiPlanCheckModel,
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
durationMinutes,
};
mux.updateRespawnConfig(sessionId, persistedConfig);
}
+102 -1
View File
@@ -5,8 +5,11 @@
* that replaces ~43 inline not-found checks across route handlers.
*/
import { join } from 'node:path';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { z } from 'zod';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse } from '../types.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
@@ -18,6 +21,59 @@ 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;
}
/**
* Reads and parses a JSON config file, returning a default value on ENOENT.
* Logs an error for any I/O failure other than a missing file.
*/
export async function readJsonConfig<T>(filePath: string, logLabel: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(`Failed to read ${logLabel}:`, err);
}
return defaultValue;
}
}
/**
* Validates that a file path (possibly containing symlinks) resolves to a location
* within the given session working directory. Returns the resolved and relative paths,
* or null if the path escapes the directory or doesn't exist.
*/
export function validateSessionFilePath(
sessionWorkingDir: string,
filePath: string
): { resolvedPath: string; relativePath: string } | null {
const fullPath = resolve(sessionWorkingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
return null;
}
const relativePath = relative(sessionWorkingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return null;
}
return { resolvedPath, relativePath };
}
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
@@ -36,6 +92,31 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session
return session;
}
/**
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
*/
export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?: string): T {
const result = schema.safeParse(body);
if (!result.success) {
const msg = errorMessage ?? result.error.issues[0]?.message ?? 'Validation failed';
throw Object.assign(new Error(msg), {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
});
}
return result.data;
}
/**
* Persist session state and broadcast a SessionUpdated event.
* Replaces the repeated two-line pattern across route handlers.
*/
export function persistAndBroadcastSession(ctx: SessionPort & EventPort, session: Session): void {
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
}
/**
* Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s").
*/
@@ -104,6 +185,26 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
return safeFields;
}
/**
* Toggles a service (watcher/manager) on or off based on an enabled flag.
* Logs start/stop to console with the given label. Runs an optional callback after starting.
*/
export function toggleService(
enabled: boolean,
service: { isRunning(): boolean; start(): void; stop(): void },
label: string,
onStart?: () => void
): void {
if (enabled && !service.isRunning()) {
service.start();
onStart?.();
console.log(`${label} started via settings change`);
} else if (!enabled && service.isRunning()) {
service.stop();
console.log(`${label} stopped via settings change`);
}
}
/**
* Auto-configure Ralph tracker for a session.
*
+43 -129
View File
@@ -7,17 +7,31 @@
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, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
}
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
async function resolveCasePath(name: string): Promise<string> {
const linkedCases = await readLinkedCases();
if (linkedCases[name]) return linkedCases[name];
return join(CASES_DIR, name);
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
@@ -45,22 +59,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
// Get linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
for (const [name, path] of Object.entries(linkedCases)) {
// Only add if not already in cases (avoid duplicates) and path exists
if (!cases.some((c) => c.name === name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
});
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
const linkedCases = await readLinkedCases();
const existingNames = new Set(cases.map((c) => c.name));
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
});
}
}
@@ -68,19 +75,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const result = CreateCaseSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { name, description } = result.data;
const { name, description } = parseBody(CreateCaseSchema, req.body);
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');
}
@@ -110,11 +108,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const lcResult = LinkCaseSchema.safeParse(req.body);
if (!lcResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { name, path: folderPath } = lcResult.data;
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
// Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
@@ -131,15 +125,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
// Load existing linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
let linkedCases: Record<string, string> = {};
try {
linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
}
}
const linkedCases = await readLinkedCases();
// Check if name is already linked
if (linkedCases[name]) {
@@ -156,7 +142,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) {
@@ -167,42 +153,22 @@ 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');
}
// First check linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
const linkedPath = linkedCases[name];
return {
name,
path: linkedPath,
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
linked: true,
};
}
} catch {
// ENOENT or parse errors - fall through to CASES_DIR check
}
// Then check CASES_DIR
const casePath = join(CASES_DIR, name);
const casePath = await resolveCasePath(name);
if (!existsSync(casePath)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
const linked = casePath !== join(CASES_DIR, name);
return {
name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
...(linked && { linked: true }),
};
});
@@ -210,30 +176,12 @@ 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');
}
// Get case path (check linked cases first, then CASES_DIR)
let casePath: string | null = null;
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
casePath = linkedCases[name];
}
} catch {
// ENOENT or parse errors - fall through to CASES_DIR
}
if (!casePath) {
casePath = join(CASES_DIR, name);
}
const casePath = await resolveCasePath(name);
const fixPlanPath = join(casePath, '@fix_plan.md');
@@ -334,28 +282,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
const { caseName } = req.params as { caseName: string };
let casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
if (!validatePathWithinBase(caseName, CASES_DIR)) {
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');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[caseName]) {
casePath = linkedCases[caseName];
}
} catch {
// No linked cases file
}
}
const casePath = await resolveCasePath(caseName);
const wizardDir = join(casePath, 'ralph-wizard');
@@ -394,33 +325,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);
if (!validatePathWithinBase(caseName, CASES_DIR)) {
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');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[caseName]) {
casePath = linkedCases[caseName];
}
} catch {
// No linked cases file
}
}
const casePath = await resolveCasePath(caseName);
const wizardDir = join(casePath, 'ralph-wizard');
+8 -22
View File
@@ -4,12 +4,11 @@
*/
import { FastifyInstance } from 'fastify';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import { join } from 'node:path';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import { findSessionOrFail } from '../route-helpers.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
@@ -148,17 +147,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
}
const relativePath = relative(session.workingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
}
const { resolvedPath } = validated;
try {
const stat = await fs.stat(resolvedPath);
@@ -255,19 +248,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const relativePath = relative(session.workingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
return;
}
const { resolvedPath } = validated;
try {
// Validate file size before reading (DoS protection - prevent memory exhaustion)
+2 -6
View File
@@ -7,7 +7,7 @@
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData } from '../route-helpers.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerHookEventRoutes(
@@ -15,11 +15,7 @@ export function registerHookEventRoutes(
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
): void {
app.post('/api/hook-event', async (req) => {
const result = HookEventSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { event, sessionId, data } = result.data;
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
if (!ctx.sessions.has(sessionId)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
+2
View File
@@ -14,3 +14,5 @@ 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 { registerOrchestratorRoutes } from './orchestrator-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+243
View File
@@ -0,0 +1,243 @@
/**
* @fileoverview Orchestrator loop routes — plan-based autonomous execution.
*
* Endpoints:
* - POST /api/orchestrator/start — Start orchestration with a goal
* - POST /api/orchestrator/approve — Approve generated plan
* - POST /api/orchestrator/reject — Reject plan with feedback
* - POST /api/orchestrator/pause — Pause execution
* - POST /api/orchestrator/resume — Resume from pause
* - POST /api/orchestrator/stop — Stop and clean up
* - GET /api/orchestrator/status — Get current status
* - GET /api/orchestrator/plan — Get current plan
* - POST /api/orchestrator/phase/:id/skip — Skip a phase
* - POST /api/orchestrator/phase/:id/retry — Retry a failed phase
*
* @module web/routes/orchestrator-routes
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, OrchestratorPort } from '../ports/index.js';
export function registerOrchestratorRoutes(app: FastifyInstance, ctx: OrchestratorPort & EventPort): void {
// ═══════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════
function getLoop() {
const loop = ctx.orchestratorLoop;
if (!loop) {
throw Object.assign(new Error('Orchestrator not initialized'), {
statusCode: 503,
body: createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'Orchestrator not initialized'),
});
}
return loop;
}
const EVENT_MAP: [string, (typeof SseEvent)[keyof typeof SseEvent], string[]][] = [
['stateChanged', SseEvent.OrchestratorStateChanged, ['state', 'prevState']],
['planProgress', SseEvent.OrchestratorPlanProgress, ['phase', 'detail']],
['planReady', SseEvent.OrchestratorPlanReady, ['plan']],
['phaseStarted', SseEvent.OrchestratorPhaseStarted, ['phase']],
['phaseCompleted', SseEvent.OrchestratorPhaseCompleted, ['phase']],
['phaseFailed', SseEvent.OrchestratorPhaseFailed, ['phase', 'reason']],
['taskAssigned', SseEvent.OrchestratorTaskAssigned, ['task', 'sessionId']],
['taskCompleted', SseEvent.OrchestratorTaskCompleted, ['task']],
['taskFailed', SseEvent.OrchestratorTaskFailed, ['task', 'error']],
['completed', SseEvent.OrchestratorCompleted, ['stats']],
];
let forwardingLoop: import('../../orchestrator-loop.js').OrchestratorLoop | null = null;
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
if (forwardingLoop === loop) return; // Already attached to this loop instance
forwardingLoop = loop;
for (const [event, sseEvent, argNames] of EVENT_MAP) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loop.on(event, (...args: any[]) => {
const payload: Record<string, unknown> = {};
argNames.forEach((name, i) => {
payload[name] = args[i];
});
ctx.broadcast(sseEvent, payload);
});
}
// Special cases with non-trivial payload transforms
loop.on('verificationResult', (phase, result) => {
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
});
loop.on('error', (error) => {
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
});
}
// ═══════════════════════════════════════════════════════════════
// Start
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/start', async (req) => {
const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body');
// Initialize loop if needed
let loop = ctx.orchestratorLoop;
if (!loop) {
loop = ctx.initOrchestratorLoop();
setupEventForwarding(loop);
}
// Check if already running
if (loop.isRunning()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Orchestrator is already running');
}
try {
// Start is async — kicks off planning
loop.start(goal).catch((err) => {
console.error('[Orchestrator Route] Start failed:', getErrorMessage(err));
});
return {
ok: true,
state: loop.state,
message: 'Orchestrator started — generating plan',
config: config ?? null,
};
} catch (err) {
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Approve / Reject Plan
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/approve', async () => {
const loop = getLoop();
try {
loop.approve().catch((err) => {
console.error('[Orchestrator Route] Approve failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/reject', async (req) => {
const loop = getLoop();
const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required');
try {
loop.reject(feedback).catch((err) => {
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Pause / Resume / Stop
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/pause', async () => {
const loop = getLoop();
try {
loop.pause();
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/resume', async () => {
const loop = getLoop();
try {
loop.resume().catch((err) => {
console.error('[Orchestrator Route] Resume failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/stop', async () => {
const loop = getLoop();
try {
await loop.stop();
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Status / Plan
// ═══════════════════════════════════════════════════════════════
app.get('/api/orchestrator/status', async () => {
const loop = ctx.orchestratorLoop;
if (!loop) {
return { ok: true, state: 'idle', plan: null, stats: null };
}
return {
ok: true,
...loop.getStatus(),
};
});
app.get('/api/orchestrator/plan', async () => {
const loop = ctx.orchestratorLoop;
if (!loop) {
return { ok: true, plan: null };
}
return {
ok: true,
plan: loop.getPlan(),
currentPhase: loop.getCurrentPhase(),
};
});
// ═══════════════════════════════════════════════════════════════
// Phase Operations
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
const loop = getLoop();
const { id } = req.params as { id: string };
try {
await loop.skipPhase(id);
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
const loop = getLoop();
const { id } = req.params as { id: string };
try {
loop.retryPhase(id).catch((err) => {
console.error('[Orchestrator Route] Retry failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
}
+13 -33
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,7 +17,7 @@ import {
PlanTaskUpdateSchema,
PlanTaskAddSchema,
} from '../schemas.js';
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -29,11 +29,11 @@ export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & Even
// ========== Generate Plan (Simple) ==========
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
const gpResult = GeneratePlanSchema.safeParse(req.body);
if (!gpResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { taskDescription, detailLevel = 'standard' } = gpResult.data;
const { taskDescription, detailLevel = 'standard' } = parseBody(
GeneratePlanSchema,
req.body,
'Invalid request body'
);
// Build sophisticated prompt based on Ralph Wiggum methodology
const detailConfig = {
@@ -223,21 +223,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// ========== Generate Plan (Detailed Orchestration) ==========
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
if (!gpdResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { taskDescription, caseName } = gpdResult.data;
const { taskDescription, caseName } = parseBody(GeneratePlanDetailedSchema, req.body, 'Invalid request body');
// 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
@@ -331,11 +323,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// ========== Cancel Plan Generation ==========
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
const cpResult = CancelPlanSchema.safeParse(req.body);
if (!cpResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { orchestratorId } = cpResult.data;
const { orchestratorId } = parseBody(CancelPlanSchema, req.body, 'Invalid request body');
// If specific orchestrator ID provided, cancel just that one
if (orchestratorId) {
@@ -378,11 +366,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
}
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
if (!ptuResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const update = ptuResult.data as {
const update = parseBody(PlanTaskUpdateSchema, req.body, 'Invalid request body') as {
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
error?: string;
incrementAttempts?: boolean;
@@ -458,11 +442,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
}
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
if (!ptaResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const task = ptaResult.data;
const task = parseBody(PlanTaskAddSchema, req.body, 'Invalid request body');
const result = tracker.addPlanTask(task);
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
+4 -10
View File
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
import { v4 as uuidv4 } from 'uuid';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { InfraPort } from '../ports/index.js';
export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
@@ -15,11 +16,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
});
app.post('/api/push/subscribe', async (req) => {
const result = PushSubscribeSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { endpoint, keys, userAgent, pushPreferences } = result.data;
const { endpoint, keys, userAgent, pushPreferences } = parseBody(PushSubscribeSchema, req.body);
const record = ctx.pushStore.addSubscription({
id: uuidv4(),
endpoint,
@@ -33,11 +30,8 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void {
app.put('/api/push/subscribe/:id', async (req) => {
const { id } = req.params as { id: string };
const result = PushPreferencesUpdateSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences);
const { pushPreferences } = parseBody(PushPreferencesUpdateSchema, req.body);
const updated = ctx.pushStore.updatePreferences(id, pushPreferences);
if (!updated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
}
+21 -62
View File
@@ -13,7 +13,7 @@ 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 { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -31,22 +31,18 @@ export function registerRalphRoutes(
// Configure Ralph tracker for a session
app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string };
const ralphResult = RalphConfigSchema.safeParse(req.body);
if (!ralphResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as {
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody(
RalphConfigSchema,
req.body,
'Invalid request body'
) as {
enabled?: boolean;
completionPhrase?: string;
maxIterations?: number;
reset?: boolean | 'full';
disableAutoEnable?: boolean;
};
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
// Ralph tracker is not supported for opencode sessions
if (session.mode === 'opencode') {
@@ -111,11 +107,7 @@ export function registerRalphRoutes(
// Reset circuit breaker for Ralph tracker
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
session.ralphTracker.resetCircuitBreaker();
return { success: true };
@@ -124,11 +116,7 @@ export function registerRalphRoutes(
// Get Ralph status block and circuit breaker state
app.get('/api/sessions/:id/ralph-status', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
return {
success: true,
@@ -148,11 +136,7 @@ export function registerRalphRoutes(
// Generate @fix_plan.md content from todos
app.get('/api/sessions/:id/fix-plan', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
const content = session.ralphTracker.generateFixPlanMarkdown();
return {
@@ -167,16 +151,8 @@ export function registerRalphRoutes(
// Import todos from @fix_plan.md content
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
const { id } = req.params as { id: string };
const importResult = FixPlanImportSchema.safeParse(req.body);
if (!importResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { content } = importResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session);
@@ -193,11 +169,7 @@ export function registerRalphRoutes(
// Write @fix_plan.md to session's working directory
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
const workingDir = session.workingDir;
if (!workingDir) {
@@ -224,11 +196,7 @@ export function registerRalphRoutes(
// Read @fix_plan.md from session's working directory and import
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
const workingDir = session.workingDir;
if (!workingDir) {
@@ -266,16 +234,8 @@ export function registerRalphRoutes(
// This avoids mux input escaping issues with long multi-line prompts
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
const { id } = req.params as { id: string };
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
if (!promptResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { content } = promptResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
const workingDir = session.workingDir;
if (!workingDir) {
@@ -308,11 +268,10 @@ export function registerRalphRoutes(
);
}
const rlResult = RalphLoopStartSchema.safeParse(req.body);
if (!rlResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed');
}
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody(
RalphLoopStartSchema,
req.body
);
const casePath = join(CASES_DIR, caseName);
@@ -505,7 +464,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 */
}
+21 -16
View File
@@ -8,10 +8,18 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa
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 { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from '../../config/ai-defaults.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
} from '../../config/ai-defaults.js';
/** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */
const noopEventPort: EventPort = {
@@ -20,6 +28,7 @@ const noopEventPort: EventPort = {
batchTerminalData: () => {},
broadcastSessionStateDebounced: () => {},
batchTaskUpdate: () => {},
getSseClientCount: () => 0,
};
export function registerRespawnRoutes(
@@ -75,11 +84,7 @@ export function registerRespawnRoutes(
const { id } = req.params as { id: string };
let body: Partial<RespawnConfig> | undefined;
if (req.body) {
const result = RespawnConfigSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
}
body = result.data as Partial<RespawnConfig>;
body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
}
const session = findSessionOrFail(ctx, id);
@@ -153,11 +158,7 @@ export function registerRespawnRoutes(
app.put('/api/sessions/:id/respawn/config', async (req) => {
const { id } = req.params as { id: string };
// Validate respawn config to prevent arbitrary field injection
const parseResult = RespawnConfigSchema.safeParse(req.body);
if (!parseResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
}
const config = parseResult.data as Partial<RespawnConfig>;
const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial<RespawnConfig>;
const session = findSessionOrFail(ctx, id);
const controller = ctx.respawnControllers.get(id);
@@ -188,14 +189,18 @@ export function registerRespawnRoutes(
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL,
aiIdleCheckMaxContext:
config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
aiIdleCheckTimeoutMs:
config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? AI_IDLE_CHECK_TIMEOUT_MS,
aiIdleCheckCooldownMs:
config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? AI_IDLE_CHECK_COOLDOWN_MS,
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL,
aiPlanCheckMaxContext:
config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
aiPlanCheckTimeoutMs:
config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? AI_PLAN_CHECK_TIMEOUT_MS,
aiPlanCheckCooldownMs:
config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? AI_PLAN_CHECK_COOLDOWN_MS,
durationMinutes: currentConfig?.durationMinutes,
};
ctx.mux.updateRespawnConfig(id, merged);
+2 -5
View File
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
import { statSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { ScheduledRunSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js';
export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void {
@@ -15,11 +16,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort &
});
app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
const srResult = ScheduledRunSchema.safeParse(req.body);
if (!srResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { prompt, workingDir, durationMinutes } = srResult.data;
const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body');
// Validate workingDir exists and is a directory
if (workingDir) {
+282 -168
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 {
@@ -32,9 +32,17 @@ import {
QuickRunSchema,
QuickStartSchema,
} from '../schemas.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
import {
autoConfigureRalph,
CASES_DIR,
findSessionOrFail,
parseBody,
persistAndBroadcastSession,
SETTINGS_PATH,
validatePathWithinBase,
} from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -51,6 +59,52 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA, CSI n;m H = CUP)
* to animate the spinner and update the status bar. During long thinking phases, these frames
* accumulate to 500KB+ of repeated overwrites to the same rows. When the buffer is tailed,
* only spinner frames are returned, making the terminal appear empty.
*
* Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
* only the last ~4KB of redraw frames (the final visual state) and discard the rest.
*/
function stripInkRedrawBloat(buffer: string): string {
// Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
// eslint-disable-next-line no-control-regex
const firstVPA = buffer.search(/\x1b\[\d+d/);
if (firstVPA === -1) return buffer; // No Ink redraws
const contentPart = buffer.slice(0, firstVPA);
const redrawPart = buffer.slice(firstVPA);
// If the redraw section is small (<16KB), not worth stripping
if (redrawPart.length < 16384) return buffer;
// Find the last complete Ink frame by searching for where the VPA row
// number drops (cursor jumps back to viewport top for a new render cycle).
// Search the last 64KB — a single Ink frame with response content can be
// 10-20KB, so 4KB was too small and caused partial frames (blank gap).
const searchLen = Math.min(redrawPart.length, 65536);
const searchWindow = redrawPart.slice(-searchLen);
// eslint-disable-next-line no-control-regex
const vpaRe = /\x1b\[(\d+)d/g;
let lastFrameStart = 0;
let prevRow = -1;
let match;
while ((match = vpaRe.exec(searchWindow)) !== null) {
const row = parseInt(match[1], 10);
// Row number dropped significantly — Ink started a new frame
if (prevRow > 0 && row < prevRow - 5) {
lastFrameStart = match.index;
}
prevRow = row;
}
return contentPart + searchWindow.slice(lastFrameStart);
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -92,11 +146,7 @@ export function registerSessionRoutes(
);
}
const result = CreateSessionSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const body = result.data;
const body = parseBody(CreateSessionSchema, req.body);
const workingDir = body.workingDir || process.cwd();
// Validate workingDir exists and is a directory
@@ -116,6 +166,11 @@ export function registerSessionRoutes(
await updateCaseEnvVars(workingDir, body.envOverrides);
}
// Write model override to .claude/settings.local.json if provided
if (body.modelOverride !== undefined) {
await updateCaseModel(workingDir, body.modelOverride || null);
}
// Check OpenCode availability if requested
if (body.mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
@@ -144,6 +199,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: body.resumeSessionId,
});
ctx.addSession(session);
@@ -163,23 +219,14 @@ export function registerSessionRoutes(
app.put('/api/sessions/:id/name', async (req) => {
const { id } = req.params as { id: string };
const result = SessionNameSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
session.name = name;
// Also update the mux session name if applicable
ctx.mux.updateSessionName(id, session.name);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
persistAndBroadcastSession(ctx, session);
return { success: true, name: session.name };
});
@@ -187,16 +234,8 @@ export function registerSessionRoutes(
app.put('/api/sessions/:id/color', async (req) => {
const { id } = req.params as { id: string };
const result = SessionColorSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
if (!validColors.includes(body.color)) {
@@ -204,8 +243,7 @@ export function registerSessionRoutes(
}
session.setColor(body.color as SessionColor);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
persistAndBroadcastSession(ctx, session);
return { success: true, color: session.color };
});
@@ -244,11 +282,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
@@ -263,11 +297,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/output', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
return {
success: true,
@@ -283,11 +313,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/ralph-state', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
return {
success: true,
@@ -303,11 +329,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/run-summary', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
const tracker = ctx.runSummaryTrackers.get(id);
if (!tracker) {
@@ -327,11 +349,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/active-tools', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
return {
success: true,
@@ -349,16 +367,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = RunPromptSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { prompt } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const { prompt } = parseBody(RunPromptSchema, req.body);
const session = findSessionOrFail(ctx, id);
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -377,11 +387,7 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -421,11 +427,7 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
@@ -455,16 +457,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = SessionInputWithLimitSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { input, useMux } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
const session = findSessionOrFail(ctx, id);
const inputStr = String(input);
if (inputStr.length > MAX_INPUT_LENGTH) {
@@ -500,16 +494,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = ResizeSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { cols, rows } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const { cols, rows } = parseBody(ResizeSchema, req.body);
const session = findSessionOrFail(ctx, id);
session.resize(cols, rows);
return { success: true };
@@ -522,21 +508,23 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/terminal', async (req) => {
const { id } = req.params as { id: string };
const query = req.query as { tail?: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const session = findSessionOrFail(ctx, id);
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = session.terminalBufferLength;
let truncated = false;
let cleanBuffer: string;
if (tailBytes > 0 && fullSize > tailBytes) {
// Strip redundant Ink spinner/status redraws BEFORE tailing.
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
const strippedBuffer = stripInkRedrawBloat(session.terminalBuffer);
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
// Banner is near the top and gets discarded by tail anyway.
cleanBuffer = session.terminalBuffer.slice(-tailBytes);
cleanBuffer = strippedBuffer.slice(-tailBytes);
truncated = true;
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
// and start from there. This prevents xterm.js from parsing a partial escape
@@ -547,7 +535,7 @@ export function registerSessionRoutes(
}
} else {
// Full buffer: clean junk before actual Claude content
cleanBuffer = session.terminalBuffer;
cleanBuffer = strippedBuffer;
// Find where Claude banner starts (has color codes before "Claude")
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
@@ -579,20 +567,11 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/auto-clear', async (req) => {
const { id } = req.params as { id: string };
const acResult = AutoClearSchema.safeParse(req.body);
if (!acResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = acResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
session.setAutoClear(body.enabled, body.threshold);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
persistAndBroadcastSession(ctx, session);
return {
success: true,
@@ -609,20 +588,11 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/auto-compact', async (req) => {
const { id } = req.params as { id: string };
const compactResult = AutoCompactSchema.safeParse(req.body);
if (!compactResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = compactResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
persistAndBroadcastSession(ctx, session);
return {
success: true,
@@ -640,16 +610,8 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/image-watcher', async (req) => {
const { id } = req.params as { id: string };
const iwResult = ImageWatcherSchema.safeParse(req.body);
if (!iwResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = iwResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir);
@@ -673,20 +635,11 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/flicker-filter', async (req) => {
const { id } = req.params as { id: string };
const ffResult = FlickerFilterSchema.safeParse(req.body);
if (!ffResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = ffResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
session.flickerFilterEnabled = body.enabled;
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
persistAndBroadcastSession(ctx, session);
return {
success: true,
@@ -711,11 +664,7 @@ export function registerSessionRoutes(
);
}
const qrResult = QuickRunSchema.safeParse(req.body);
if (!qrResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { prompt, workingDir } = qrResult.data;
const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
if (!prompt.trim()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
@@ -771,11 +720,7 @@ export function registerSessionRoutes(
);
}
const result = QuickStartSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body);
// Check OpenCode availability if requested
if (mode === 'opencode') {
@@ -788,13 +733,8 @@ export function registerSessionRoutes(
}
}
const casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
const casePath = validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
@@ -922,4 +862,178 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// History — list past Claude conversations for resume
// ═══════════════════════════════════════════════════════════════
/** Extract the text of the first user message from a JSONL transcript head. */
function extractFirstUserPrompt(head: string): string | undefined {
const MAX_PROMPT_LEN = 120;
// Iterate lines without allocating a full split array
let start = 0;
while (start < head.length) {
const end = head.indexOf('\n', start);
const line = end === -1 ? head.slice(start) : head.slice(start, end);
start = end === -1 ? head.length : end + 1;
if (!line.includes('"type":"user"')) continue;
try {
const entry = JSON.parse(line);
if (entry.type !== 'user' || !entry.message) continue;
const content = entry.message.content;
let text: string | undefined;
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
const textBlock = content.find((b: { type: string }) => b.type === 'text');
if (textBlock) text = textBlock.text;
}
if (!text) continue;
// Strip XML-like system/command tags and ANSI escapes from transcripts
text = text
.replace(/<[^>]+>/g, '')
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
.trim()
.replace(/\s+/g, ' ');
if (!text) continue;
// Skip system-injected messages, slash command artifacts, and expanded skill prompts
if (
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(text) ||
/^(Please )?(analyze|review) this codebase/i.test(text) ||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(text) ||
/^\d+ vulnerabilit/i.test(text) ||
/\btoolu_/.test(text) ||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(text) ||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(text) ||
text.length < 8
)
continue;
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
} catch {
// Malformed line — skip
}
}
return undefined;
}
/** Read the first 16KB of a file for content sniffing. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try {
const fd = await fs.open(path, 'r');
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
await fd.close();
return buf.toString('utf8', 0, bytesRead);
} catch {
return null;
}
}
/** Read the last `buf.length` bytes of a file (for tail-scanning user prompts). */
async function readFileTail(path: string, buf: Buffer, fileSize: number): Promise<string | null> {
try {
const fd = await fs.open(path, 'r');
const offset = Math.max(0, fileSize - buf.length);
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
await fd.close();
const text = buf.toString('utf8', 0, bytesRead);
// Skip first partial line when we didn't read from the start
if (offset > 0) {
const nl = text.indexOf('\n');
return nl >= 0 ? text.slice(nl + 1) : null;
}
return text;
} catch {
return null;
}
}
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;
firstPrompt?: string;
}> = [];
const headBuf = Buffer.alloc(16384);
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.
// Read first 16KB to check content and extract first user prompt.
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
if (fileStat.size < 50000) {
if (
!head ||
(!head.includes('"type":"user"') &&
!head.includes('"type":"assistant"') &&
!head.includes('"type":"summary"'))
) {
continue; // No conversation content — skip
}
}
if (head) firstPrompt = extractFirstUserPrompt(head);
// If head scan found no usable prompt (e.g. session started with /init),
// try reading the tail for a recent user message.
if (!firstPrompt && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768);
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
results.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
});
}
}
} 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) };
});
}
+52 -98
View File
@@ -24,7 +24,14 @@ import {
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 {
findSessionOrFail,
formatUptime,
parseBody,
readJsonConfig,
toggleService,
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';
@@ -104,6 +111,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) {
@@ -264,15 +288,20 @@ export function registerSystemRoutes(
// ========== Stats ==========
app.get('/api/stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
function collectActiveTokens(): Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> {
const tokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
for (const [sessionId, session] of ctx.sessions) {
activeSessionTokens[sessionId] = {
tokens[sessionId] = {
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
};
}
return tokens;
}
app.get('/api/stats', async () => {
const activeSessionTokens = collectActiveTokens();
return {
success: true,
stats: ctx.store.getAggregateStats(activeSessionTokens),
@@ -281,14 +310,7 @@ export function registerSystemRoutes(
});
app.get('/api/token-stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
for (const [sessionId, session] of ctx.sessions) {
activeSessionTokens[sessionId] = {
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
};
}
const activeSessionTokens = collectActiveTokens();
return {
success: true,
daily: ctx.store.getDailyStats(30),
@@ -307,11 +329,8 @@ export function registerSystemRoutes(
});
app.put('/api/config', async (req) => {
const parseResult = ConfigUpdateSchema.safeParse(req.body);
if (!parseResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
}
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { success: true, config: ctx.store.getConfig() };
});
@@ -379,23 +398,11 @@ export function registerSystemRoutes(
// ========== Settings ==========
app.get('/api/settings', async () => {
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
return JSON.parse(content);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read settings:', err);
}
}
return {};
return readJsonConfig(SETTINGS_PATH, 'settings', {});
});
app.put('/api/settings', async (req) => {
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
if (!settingsResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
}
const settings = settingsResult.data as Record<string, unknown>;
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
try {
const dir = dirname(SETTINGS_PATH);
@@ -412,30 +419,17 @@ export function registerSystemRoutes(
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// Handle subagent tracking toggle dynamically
const subagentEnabled = settings.subagentTrackingEnabled ?? true;
if (subagentEnabled && !subagentWatcher.isRunning()) {
subagentWatcher.start();
console.log('Subagent watcher started via settings change');
} else if (!subagentEnabled && subagentWatcher.isRunning()) {
subagentWatcher.stop();
console.log('Subagent watcher stopped via settings change');
}
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// Handle image watcher toggle dynamically
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
imageWatcher.start();
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
imageWatcher.watchSession(session.id, session.workingDir);
}
}
console.log('Image watcher started via settings change');
} else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
imageWatcher.stop();
console.log('Image watcher stopped via settings change');
}
});
// Handle tunnel toggle dynamically
if ('tunnelEnabled' in settings) {
@@ -462,24 +456,12 @@ export function registerSystemRoutes(
// ========== Model Configuration ==========
app.get('/api/execution/model-config', async () => {
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(content);
return { success: true, data: settings.modelConfig || {} };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read model config:', err);
}
return { success: true, data: {} };
}
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'model config', {});
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
if (!mcResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
}
const modelConfig = mcResult.data as Record<string, unknown>;
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
let existingSettings: Record<string, unknown> = {};
@@ -519,11 +501,7 @@ export function registerSystemRoutes(
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
const clResult = CpuLimitSchema.safeParse(req.body);
if (!clResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = clResult.data as Partial<NiceConfig>;
const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial<NiceConfig>;
session.setNice(body);
ctx.persistSessionState(session);
@@ -543,23 +521,11 @@ export function registerSystemRoutes(
// ========== Subagent Window State Persistence ==========
app.get('/api/subagent-window-states', async () => {
try {
const content = await fs.readFile(windowStatesPath, 'utf-8');
return JSON.parse(content);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read subagent window states:', err);
}
}
return { minimized: {}, open: [] };
return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] });
});
app.put('/api/subagent-window-states', async (req) => {
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
if (!swResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
}
const states = swResult.data as Record<string, unknown>;
const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record<string, unknown>;
try {
const dir = dirname(windowStatesPath);
if (!existsSync(dir)) {
@@ -575,23 +541,11 @@ export function registerSystemRoutes(
// ========== Subagent Parent Associations ==========
app.get('/api/subagent-parents', async () => {
try {
const content = await fs.readFile(parentMapPath, 'utf-8');
return JSON.parse(content);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read subagent parent map:', err);
}
}
return {};
return readJsonConfig(parentMapPath, 'subagent parent map', {});
});
app.put('/api/subagent-parents', async (req) => {
const spResult = SubagentParentMapSchema.safeParse(req.body);
if (!spResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
}
const parentMap = spResult.data;
const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map');
try {
const dir = dirname(parentMapPath);
if (!existsSync(dir)) {
@@ -692,7 +646,7 @@ export function registerSystemRoutes(
for await (const chunk of req.raw) {
totalSize += chunk.length;
if (totalSize > MAX_SCREENSHOT_SIZE) {
reply.status(413);
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
}
chunks.push(chunk as Buffer);
@@ -777,12 +731,12 @@ export function registerSystemRoutes(
const { name } = req.params as { name: string };
// Prevent path traversal
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
reply.status(400);
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
}
const filepath = join(SCREENSHOTS_DIR, name);
if (!existsSync(filepath)) {
reply.status(404);
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
}
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
+206
View File
@@ -0,0 +1,206 @@
/**
* @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';
/** Max concurrent WS connections per session. Prevents listener/bandwidth multiplication. */
const MAX_WS_PER_SESSION = 5;
/** Track active WS connections per session for connection limiting. */
const sessionWsCount = new Map<string, number>();
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;
}
// Enforce per-session connection limit
const currentCount = sessionWsCount.get(id) ?? 0;
if (currentCount >= MAX_WS_PER_SESSION) {
socket.close(4008, 'Too many connections');
return;
}
sessionWsCount.set(id, currentCount + 1);
// Swallow socket errors — cleanup happens in 'close'
socket.on('error', () => {});
// 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) => {
if (socket.readyState !== 1) return;
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"}');
}
};
// Close WS when session exits (deleted, respawned, or crashed) — prevents
// orphaned listeners and stale writes to a dead PTY.
const onSessionExit = () => {
socket.close(4009, 'Session terminated');
};
session.on('terminal', onTerminal);
session.on('clearTerminal', onClearTerminal);
session.on('needsRefresh', onNeedsRefresh);
session.on('exit', onSessionExit);
// Heartbeat: detect stale connections (especially through tunnels where
// TCP RST can take minutes to propagate).
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
socket.on('pong', () => {
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
});
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) return;
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);
session.off('exit', onSessionExit);
// Decrement per-session connection count
const count = sessionWsCount.get(id) ?? 1;
if (count <= 1) {
sessionWsCount.delete(id);
} else {
sessionWsCount.set(id, count - 1);
}
});
});
}
+61 -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 ==========
@@ -124,7 +124,15 @@ export const CreateSessionSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
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 +323,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();
@@ -550,3 +582,31 @@ export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
// ========== Orchestrator Loop ==========
/** POST /api/orchestrator/start */
export const OrchestratorStartSchema = z.object({
goal: z.string().min(1).max(100000),
config: z
.object({
plannerModel: z.string().max(100).optional(),
researchEnabled: z.boolean().optional(),
autoApprove: z.boolean().optional(),
maxPhaseRetries: z.number().int().min(1).max(10).optional(),
phaseTimeoutMs: z.number().int().min(60000).max(7200000).optional(),
enableTeamAgents: z.boolean().optional(),
maxParallelSessions: z.number().int().min(1).max(10).optional(),
verificationMode: z.enum(['strict', 'moderate', 'lenient']).optional(),
compactBetweenPhases: z.boolean().optional(),
})
.optional(),
});
/** POST /api/orchestrator/reject */
export const OrchestratorRejectSchema = z.object({
feedback: z.string().min(1).max(10000),
});
export type OrchestratorStartInput = z.infer<typeof OrchestratorStartSchema>;
export type OrchestratorRejectInput = z.infer<typeof OrchestratorRejectSchema>;
+180 -1045
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
/**
* @fileoverview Session event listener wiring — creates, attaches, and detaches session listeners.
*
* Extracted from server.ts for modularity. Provides:
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
*
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
* in server.ts (_doCleanupSession, exit handler, stop()).
*
* @dependencies session.ts (Session, event types), sse-events.ts, types.ts
* @consumedby web/server.ts (WebServer delegates listener lifecycle here)
*
* @module web/session-listener-wiring
*/
import type {
Session,
ClaudeMessage,
BackgroundTask,
RalphTrackerState,
RalphTodoItem,
ActiveBashTool,
} from '../session.js';
import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js';
import { SseEvent } from './sse-events.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
terminal: (data: string) => void;
clearTerminal: () => void;
needsRefresh: () => void;
message: (msg: ClaudeMessage) => void;
error: (error: string) => void;
completion: (result: string, cost: number) => void;
exit: (code: number | null) => void;
working: () => void;
idle: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
taskFailed: (task: BackgroundTask, error: string) => void;
autoClear: (data: { tokens: number; threshold: number }) => void;
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
ralphLoopUpdate: (state: RalphTrackerState) => void;
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
ralphCompletionDetected: (phrase: string) => void;
ralphStatusBlockDetected: (block: RalphStatusBlock) => void;
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
bashToolStart: (tool: ActiveBashTool) => void;
bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
export interface SessionListenerDeps {
broadcast(event: string, data: unknown): void;
batchTerminalData(sessionId: string, data: string): void;
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
persistSessionState(session: Session): void;
getSessionStateWithRespawn(session: Session): unknown;
getRunSummaryTracker(sessionId: string): import('../run-summary.js').RunSummaryTracker | undefined;
stopTranscriptWatcher(sessionId: string): void;
cleanupSessionBatches(sessionId: string): void;
cancelPersistDebounce(sessionId: string): void;
removeRunSummaryTracker(sessionId: string): void;
removeSessionListenerRefs(sessionId: string): void;
cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore;
}
/**
* Creates all 25 session listener handlers, capturing dependencies via closure.
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
return {
// ─── Terminal Output ─────────────────────────────────────
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
terminal: (data) => {
deps.batchTerminalData(session.id, data);
},
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
clearTerminal: () => {
deps.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
},
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer */
needsRefresh: () => {
deps.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
},
// ─── Session Messages & Errors ──────────────────────────
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
message: (msg: ClaudeMessage) => {
deps.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
},
/** Broadcasts `session:error` + sends push notification */
error: (error) => {
deps.broadcast(SseEvent.SessionError, { id: session.id, error });
deps.sendPushNotifications(SseEvent.SessionError, {
sessionId: session.id,
sessionName: session.name,
error: String(error),
});
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordError('Session error', String(error));
},
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
completion: (result, cost) => {
deps.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
deps.persistSessionState(session);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
},
// ─── Session Lifecycle ──────────────────────────────────
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
exit: (code) => {
getLifecycleLog().log({
event: 'exit',
sessionId: session.id,
name: session.name,
exitCode: code,
});
// Wrap in try/catch to ensure cleanup always happens
try {
deps.broadcast(SseEvent.SessionExit, { id: session.id, code });
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
deps.persistSessionState(session);
} catch (err) {
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
}
// Always clean up respawn controller, even if broadcast failed
try {
deps.cleanupRespawnOnExit(session.id);
} catch (err) {
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
}
// Clean up per-session resources that are stale after PTY exit.
try {
// Transcript watcher is tied to the specific PTY run
deps.stopTranscriptWatcher(session.id);
// Finalize run summary tracker
deps.removeRunSummaryTracker(session.id);
// Flush/clear terminal batching state (no more output coming)
deps.cleanupSessionBatches(session.id);
// Clear pending persist-debounce timer
deps.cancelPersistDebounce(session.id);
// Close any active file streams
fileStreamManager.closeSessionStreams(session.id);
// Remove stored listener refs to break closure references (prevents memory leak).
deps.removeSessionListenerRefs(session.id);
} catch (err) {
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
}
},
// ─── Activity State ─────────────────────────────────────
/** Broadcasts `session:working` — Claude started processing */
working: () => {
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordWorking();
tracker.recordTokens(session.inputTokens, session.outputTokens);
}
},
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
idle: () => {
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordIdle();
tracker.recordTokens(session.inputTokens, session.outputTokens);
}
},
// ─── Background Task Events ──────────────────────────────
/** Broadcasts `task:created` — new background task discovered */
taskCreated: (task: BackgroundTask) => {
deps.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
deps.broadcastSessionStateDebounced(session.id);
},
/** Batched broadcast of `task:updated` — high-frequency progress updates */
taskUpdated: (task: BackgroundTask) => {
deps.batchTaskUpdate(session.id, task);
},
/** Broadcasts `task:completed` — background task finished successfully */
taskCompleted: (task: BackgroundTask) => {
deps.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
deps.broadcastSessionStateDebounced(session.id);
},
/** Broadcasts `task:failed` — background task errored */
taskFailed: (task: BackgroundTask, error: string) => {
deps.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
deps.broadcastSessionStateDebounced(session.id);
},
// ─── Auto-Operations ────────────────────────────────────
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
autoClear: (data: { tokens: number; threshold: number }) => {
deps.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
},
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
deps.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
},
// ─── CLI Info ────────────────────────────────────────────
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
deps.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
},
// ─── Ralph Tracking Events ──────────────────────────────
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
ralphLoopUpdate: (state: RalphTrackerState) => {
deps.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
deps.getStore().updateRalphState(session.id, { loop: state });
},
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
deps.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
deps.getStore().updateRalphState(session.id, { todos });
},
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
ralphCompletionDetected: (phrase: string) => {
deps.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
deps.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
sessionId: session.id,
sessionName: session.name,
phrase,
});
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordRalphCompletion(phrase);
},
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
ralphStatusBlockDetected: (block: RalphStatusBlock) => {
deps.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.addEvent(
block.status === 'BLOCKED' ? 'warning' : 'idle_detected',
block.status === 'BLOCKED' ? 'warning' : 'info',
`Ralph Status: ${block.status}`,
`Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`
);
}
},
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => {
deps.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker && status.state === 'OPEN') {
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
}
},
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
deps.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.addEvent(
'ralph_completion',
'success',
'Exit Gate Met',
`Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`
);
}
},
// ─── Bash Tool Tracking ────────────────────────────────
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
bashToolStart: (tool: ActiveBashTool) => {
deps.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
},
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
bashToolEnd: (tool: ActiveBashTool) => {
deps.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
},
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
bashToolsUpdate: (tools: ActiveBashTool[]) => {
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
},
};
}
/** Attach all listeners to a session. */
export function attachSessionListeners(session: Session, refs: SessionListenerRefs): void {
session.on('terminal', refs.terminal);
session.on('clearTerminal', refs.clearTerminal);
session.on('needsRefresh', refs.needsRefresh);
session.on('message', refs.message);
session.on('error', refs.error);
session.on('completion', refs.completion);
session.on('exit', refs.exit);
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
session.on('taskCompleted', refs.taskCompleted);
session.on('taskFailed', refs.taskFailed);
session.on('autoClear', refs.autoClear);
session.on('autoCompact', refs.autoCompact);
session.on('cliInfoUpdated', refs.cliInfoUpdated);
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
session.on('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
session.on('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
session.on('ralphExitGateMet', refs.ralphExitGateMet);
session.on('bashToolStart', refs.bashToolStart);
session.on('bashToolEnd', refs.bashToolEnd);
session.on('bashToolsUpdate', refs.bashToolsUpdate);
}
/** Detach all listeners from a session (prevents memory leaks from closure references). */
export function detachSessionListeners(session: Session, refs: SessionListenerRefs): void {
session.off('terminal', refs.terminal);
session.off('clearTerminal', refs.clearTerminal);
session.off('needsRefresh', refs.needsRefresh);
session.off('message', refs.message);
session.off('error', refs.error);
session.off('completion', refs.completion);
session.off('exit', refs.exit);
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
session.off('taskCompleted', refs.taskCompleted);
session.off('taskFailed', refs.taskFailed);
session.off('autoClear', refs.autoClear);
session.off('autoCompact', refs.autoCompact);
session.off('cliInfoUpdated', refs.cliInfoUpdated);
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
session.off('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
session.off('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
session.off('ralphExitGateMet', refs.ralphExitGateMet);
session.off('bashToolStart', refs.bashToolStart);
session.off('bashToolEnd', refs.bashToolEnd);
session.off('bashToolsUpdate', refs.bashToolsUpdate);
}

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