mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods Replace ~80 inline addListener closures in connectSSE() with a declarative _SSE_HANDLER_MAP array that drives registration in a single loop. Each handler is now a named _on* method on CodemanApp, making them individually addressable for LLM navigation. Add SSE_EVENTS constant object in constants.js to eliminate magic event-type strings scattered across the frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix inaccuracies in CLAUDE.md - Fix types barrel path: src/types.ts → src/types/index.ts - Update app.js line count: ~12K → ~11.5K - Correct route handler counts (113 → 111, per-group fixes) - Add code style, ESM gotcha, env vars, route test, lifecycle log docs - Add Node 22 CI note, test teardown timeout, port range Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add mobile screenshots and QR auth security writeup to README Add 3 mobile screenshots (landing, idle, active) and expand the mobile section with QR auth security design details and a touch-optimized interface subsection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook Build and postinstall now bundle the local xterm-zerolag-input package as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay shim. Add git pre-commit hook that runs prettier --check on staged .ts files to catch format issues before CI. Also bump constants.js and app.js cache-bust versions to 0.3.0 and add tunnel upload URL display row in settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add cloudflared install support and interactive launch menu - Add optional cloudflared dependency detection and installation across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE) - Add tunnel systemd service setup helper - Replace post-install instructions with interactive launch menu (run now / systemd service / skip) - Uninstall now cleans up both codeman-web and codeman-tunnel services Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: gitignore readme-preview.mjs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression - Migrate broadcast() string literals → SseEvent.* typed constants - Add @fileoverview with cross-domain references to all 13 type domain files - Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.) - Add section dividers to route files for LLM scanability - Compress CLAUDE.md: flat file list → domain table, fix counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: optimize codebase for LLM context window efficiency CLAUDE.md: 456 → 309 lines (32% reduction) - Merge Commands into compact table, remove redundant bash block - Convert Security section to dense table format - Merge Performance + Resource Limits, Debugging + Troubleshooting - Compress Tunnel, Memory Leak, Scripts, Screenshots sections - Remove Key Patterns that duplicate @fileoverview in source files Backend @fileoverview enhancements (10 priority files): - session.ts: key methods, events, cross-domain refs - respawn-controller.ts: state machine, idle detection layers - ralph-tracker.ts: exports, circuit breaker, events - ralph-loop.ts: lifecycle, persistence, events - subagent-watcher.ts: watched patterns, teammate detection - server.ts: coordination list, port interfaces - state-store.ts: dual-file persistence, migration - session-manager.ts: lifecycle methods, mutex guard - hooks-config.ts: hook events list, categories - sse-events.ts: category breakdown (~90 events, 17 categories) Frontend app.js: add 6 section dividers, update @fileoverview line refs Fix: escape glob `*/` in JSDoc that broke ESLint parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR #29 review bugs - server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant - install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first) - install.sh: replace Arch pacman (AUR-only) with direct binary download - index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0 - mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js) - types/push.ts: fix layer number (4, not 5) - subagent-watcher.ts: fix watched pattern path to include {session} segment - constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* @fileoverview Centralized API fetch helpers mixed into CodemanApp.prototype.
|
||||
*
|
||||
* Provides _api(), _apiJson(), _apiPost(), _apiPut(), _apiDelete() methods that handle
|
||||
* JSON serialization, Content-Type headers, and error swallowing. All API calls in the
|
||||
* frontend route through these helpers.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class must be defined)
|
||||
* @loadorder 8 of 9 — loaded after app.js
|
||||
*/
|
||||
|
||||
// Codeman — Centralized API fetch helpers for CodemanApp
|
||||
// Loaded after app.js (needs CodemanApp class defined)
|
||||
|
||||
|
||||
+1297
-1079
File diff suppressed because it is too large
Load Diff
+133
-6
@@ -1,8 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules.
|
||||
*
|
||||
* This is the first script loaded in index.html. Every other frontend module depends on the
|
||||
* globals defined here: timing constants, Z-index layers, DEC 2026 sync markers, respawn
|
||||
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml, extractSyncSegments,
|
||||
* getEventCoords, scheduleBackground, urlBase64ToUint8Array).
|
||||
*
|
||||
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
|
||||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||||
* @globals {function} extractSyncSegments - DEC 2026 terminal sync marker parser
|
||||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
|
||||
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
|
||||
*
|
||||
* @dependency None (first in load order)
|
||||
* @loadorder 1 of 9 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
|
||||
* → keyboard-accessory.js → app.js → ralph-wizard.js → api-client.js → subagent-windows.js
|
||||
*/
|
||||
|
||||
// Codeman — Shared constants and utility functions for frontend modules
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Web Push Utilities
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Convert a base64-encoded VAPID key to Uint8Array for pushManager.subscribe() */
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -16,9 +37,9 @@ function urlBase64ToUint8Array(base64String) {
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 5000;
|
||||
@@ -157,9 +178,115 @@ const BUILTIN_RESPAWN_PRESETS = [
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SSE Event Types
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** @type {Record<string, string>} Centralized SSE event type constants */
|
||||
const SSE_EVENTS = {
|
||||
// Core
|
||||
INIT: 'init',
|
||||
|
||||
// Session lifecycle
|
||||
SESSION_CREATED: 'session:created',
|
||||
SESSION_UPDATED: 'session:updated',
|
||||
SESSION_DELETED: 'session:deleted',
|
||||
SESSION_TERMINAL: 'session:terminal',
|
||||
SESSION_NEEDS_REFRESH: 'session:needsRefresh',
|
||||
SESSION_CLEAR_TERMINAL: 'session:clearTerminal',
|
||||
SESSION_COMPLETION: 'session:completion',
|
||||
SESSION_ERROR: 'session:error',
|
||||
SESSION_EXIT: 'session:exit',
|
||||
SESSION_IDLE: 'session:idle',
|
||||
SESSION_WORKING: 'session:working',
|
||||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
SCHEDULED_UPDATED: 'scheduled:updated',
|
||||
SCHEDULED_COMPLETED: 'scheduled:completed',
|
||||
SCHEDULED_STOPPED: 'scheduled:stopped',
|
||||
|
||||
// Respawn
|
||||
RESPAWN_STARTED: 'respawn:started',
|
||||
RESPAWN_STOPPED: 'respawn:stopped',
|
||||
RESPAWN_STATE_CHANGED: 'respawn:stateChanged',
|
||||
RESPAWN_CYCLE_STARTED: 'respawn:cycleStarted',
|
||||
RESPAWN_BLOCKED: 'respawn:blocked',
|
||||
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
|
||||
RESPAWN_DETECTION_UPDATE: 'respawn:detectionUpdate',
|
||||
RESPAWN_TIMER_STARTED: 'respawn:timerStarted',
|
||||
RESPAWN_TIMER_CANCELLED: 'respawn:timerCancelled',
|
||||
RESPAWN_TIMER_COMPLETED: 'respawn:timerCompleted',
|
||||
RESPAWN_ERROR: 'respawn:error',
|
||||
RESPAWN_ACTION_LOG: 'respawn:actionLog',
|
||||
|
||||
// Tasks
|
||||
TASK_CREATED: 'task:created',
|
||||
TASK_COMPLETED: 'task:completed',
|
||||
TASK_FAILED: 'task:failed',
|
||||
TASK_UPDATED: 'task:updated',
|
||||
|
||||
// Mux (tmux)
|
||||
MUX_CREATED: 'mux:created',
|
||||
MUX_KILLED: 'mux:killed',
|
||||
MUX_DIED: 'mux:died',
|
||||
MUX_STATS_UPDATED: 'mux:statsUpdated',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
|
||||
SESSION_RALPH_COMPLETION_DETECTED: 'session:ralphCompletionDetected',
|
||||
SESSION_RALPH_STATUS_UPDATE: 'session:ralphStatusUpdate',
|
||||
SESSION_CIRCUIT_BREAKER_UPDATE: 'session:circuitBreakerUpdate',
|
||||
SESSION_EXIT_GATE_MET: 'session:exitGateMet',
|
||||
|
||||
// Bash tools
|
||||
SESSION_BASH_TOOL_START: 'session:bashToolStart',
|
||||
SESSION_BASH_TOOL_END: 'session:bashToolEnd',
|
||||
SESSION_BASH_TOOLS_UPDATE: 'session:bashToolsUpdate',
|
||||
|
||||
// Hooks (Claude Code hook events)
|
||||
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
|
||||
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
|
||||
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
|
||||
HOOK_STOP: 'hook:stop',
|
||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||
|
||||
// Subagents (Claude Code background agents)
|
||||
SUBAGENT_DISCOVERED: 'subagent:discovered',
|
||||
SUBAGENT_UPDATED: 'subagent:updated',
|
||||
SUBAGENT_TOOL_CALL: 'subagent:tool_call',
|
||||
SUBAGENT_PROGRESS: 'subagent:progress',
|
||||
SUBAGENT_MESSAGE: 'subagent:message',
|
||||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||||
SUBAGENT_COMPLETED: 'subagent:completed',
|
||||
|
||||
// Images
|
||||
IMAGE_DETECTED: 'image:detected',
|
||||
|
||||
// Tunnel
|
||||
TUNNEL_STARTED: 'tunnel:started',
|
||||
TUNNEL_STOPPED: 'tunnel:stopped',
|
||||
TUNNEL_PROGRESS: 'tunnel:progress',
|
||||
TUNNEL_ERROR: 'tunnel:error',
|
||||
TUNNEL_QR_ROTATED: 'tunnel:qrRotated',
|
||||
TUNNEL_QR_REGENERATED: 'tunnel:qrRegenerated',
|
||||
TUNNEL_QR_AUTH_USED: 'tunnel:qrAuthUsed',
|
||||
|
||||
// Plan orchestration
|
||||
PLAN_SUBAGENT: 'plan:subagent',
|
||||
PLAN_PROGRESS: 'plan:progress',
|
||||
PLAN_STARTED: 'plan:started',
|
||||
PLAN_CANCELLED: 'plan:cancelled',
|
||||
PLAN_COMPLETED: 'plan:completed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get unified coordinates from mouse or touch event.
|
||||
|
||||
+14
-10
@@ -20,7 +20,7 @@
|
||||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js?v=0.2.9"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.0"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
@@ -961,6 +961,10 @@
|
||||
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code" style="font-size:16px; padding:2px 6px; background:none; border:1px solid var(--border); border-radius:4px; cursor:pointer; color:var(--text-secondary)">⊞</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item" id="tunnelUploadUrlRow" style="display:none">
|
||||
<span class="settings-item-label">Upload URL</span>
|
||||
<span id="tunnelUploadUrlDisplay" class="settings-item-value" style="cursor:pointer; text-decoration:underline; font-family:monospace; font-size:12px" title="Click to copy"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1670,14 +1674,14 @@
|
||||
<!-- Lines drawn dynamically -->
|
||||
</svg>
|
||||
|
||||
<script defer src="constants.js?v=0.2.9"></script>
|
||||
<script defer src="mobile-handlers.js?v=0.2.9"></script>
|
||||
<script defer src="voice-input.js?v=0.2.9"></script>
|
||||
<script defer src="notification-manager.js?v=0.2.9"></script>
|
||||
<script defer src="keyboard-accessory.js?v=0.2.9"></script>
|
||||
<script defer src="app.js?v=0.2.9"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.2.9"></script>
|
||||
<script defer src="api-client.js?v=0.2.9"></script>
|
||||
<script defer src="subagent-windows.js?v=0.2.9"></script>
|
||||
<script defer src="constants.js?v=0.3.0"></script>
|
||||
<script defer src="mobile-handlers.js?v=0.3.0"></script>
|
||||
<script defer src="voice-input.js?v=0.3.0"></script>
|
||||
<script defer src="notification-manager.js?v=0.3.0"></script>
|
||||
<script defer src="keyboard-accessory.js?v=0.3.0"></script>
|
||||
<script defer src="app.js?v=0.3.0"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.3.0"></script>
|
||||
<script defer src="api-client.js?v=0.3.0"></script>
|
||||
<script defer src="subagent-windows.js?v=0.3.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||
*
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||
* and other modal dialogs.
|
||||
*
|
||||
* @globals {object} KeyboardAccessoryBar
|
||||
* @globals {class} FocusTrap
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||
* @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal)
|
||||
* @loadorder 5 of 9 — loaded after notification-manager.js, before app.js
|
||||
*/
|
||||
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
@@ -209,9 +232,9 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Accessibility: Focus Trap for Modals
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* FocusTrap - Traps keyboard focus within an element (typically a modal).
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
/**
|
||||
* @fileoverview Mobile device support: detection, keyboard handling, and swipe navigation.
|
||||
*
|
||||
* Defines three singleton objects that manage mobile-specific behavior:
|
||||
*
|
||||
* - MobileDetection — Device type detection (mobile/tablet/desktop), touch capability,
|
||||
* iOS/Safari identification, and body class management for CSS targeting.
|
||||
* - KeyboardHandler — Virtual keyboard show/hide detection via visualViewport API,
|
||||
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
|
||||
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
|
||||
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
|
||||
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
|
||||
*
|
||||
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
|
||||
* reconnect (in handleInit) to prevent stale closures.
|
||||
*
|
||||
* @globals {object} MobileDetection
|
||||
* @globals {object} KeyboardHandler
|
||||
* @globals {object} SwipeHandler
|
||||
*
|
||||
* @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check)
|
||||
* @loadorder 2 of 9 — loaded after constants.js, before voice-input.js
|
||||
*/
|
||||
|
||||
// Codeman — Mobile detection, keyboard handling, and swipe navigation
|
||||
// Loaded after constants.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Detection
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* MobileDetection - Detects device type and touch capability.
|
||||
@@ -109,9 +133,9 @@ const MobileDetection = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Handler
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears.
|
||||
@@ -387,9 +411,9 @@ const KeyboardHandler = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Swipe Handler
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* SwipeHandler - Detects horizontal swipes on terminal to switch sessions.
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
/**
|
||||
* @fileoverview Five-layer notification system for session events and alerts.
|
||||
*
|
||||
* The NotificationManager class implements five notification layers:
|
||||
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
||||
* 2. Tab title flash (alternating "(*) Codeman" when tab is hidden)
|
||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
*
|
||||
* Features:
|
||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
|
||||
* - Device-specific defaults (notifications disabled on mobile by default)
|
||||
* - 5s notification grouping window to batch rapid-fire events
|
||||
* - 100-notification cap with oldest eviction
|
||||
* - Rate limiting: 3s between browser notifications
|
||||
* - Visibility tracking (pauses title flash when tab becomes visible)
|
||||
* - iOS Safari bfcache support via pageshow event
|
||||
*
|
||||
* @class NotificationManager
|
||||
* @param {CodemanApp} app - Reference to the main app instance
|
||||
*
|
||||
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
||||
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
|
||||
* @loadorder 4 of 9 — loaded after voice-input.js, before keyboard-accessory.js
|
||||
*/
|
||||
|
||||
// Codeman — Multi-layer notification system
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
/**
|
||||
* Ralph Loop Wizard — extracted from app.js for maintainability.
|
||||
* Extends CodemanApp.prototype with wizard methods.
|
||||
* Loaded after app.js in index.html.
|
||||
* @fileoverview Ralph Loop Wizard — multi-step modal for configuring autonomous task loops.
|
||||
*
|
||||
* Extends CodemanApp.prototype with wizard methods for the Ralph Loop setup flow:
|
||||
* Step 1: Task description, completion phrase, iteration limit, case selection
|
||||
* Step 2: AI-powered plan generation (optional) with research agent → planner agent pipeline
|
||||
* Step 3: Respawn configuration (idle timeout, kickstart prompt, auto-clear/init)
|
||||
* Step 4: Review and launch
|
||||
*
|
||||
* Features:
|
||||
* - Plan generation via POST /api/sessions/:id/plan/generate with SSE progress streaming
|
||||
* - Existing @fix_plan.md detection and reuse
|
||||
* - Plan detail level selection (brief/detailed/comprehensive)
|
||||
* - Case selector population from /api/cases
|
||||
* - Focus trap for modal accessibility
|
||||
* - Abort controller for cancelling in-flight plan generation
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class must be defined)
|
||||
* @dependency keyboard-accessory.js (FocusTrap class for modal focus management)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 7 of 9 — loaded after app.js, before api-client.js
|
||||
*/
|
||||
|
||||
// ========== Ralph Loop Wizard ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Loop Wizard
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
@@ -388,7 +408,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Plan Generation ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Generation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
resetPlanGenerationUI() {
|
||||
// Hide all plan generation states
|
||||
@@ -780,7 +802,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closePlanSubagentWindows();
|
||||
},
|
||||
|
||||
// ========== Plan Subagent Windows ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Subagent Windows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
handlePlanSubagentEvent(event) {
|
||||
if (this.planGenerationStopped) return;
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* @fileoverview Subagent floating window management mixed into CodemanApp.prototype.
|
||||
*
|
||||
* Extends CodemanApp with methods for managing floating terminal windows that display
|
||||
* Claude Code background agent (subagent) output. Each subagent window has its own
|
||||
* xterm.js terminal instance, drag/resize handles, minimize/close controls, and
|
||||
* connection lines drawn to the parent session tab.
|
||||
*
|
||||
* Key functionality:
|
||||
* - Tab badge dropdown showing minimized agents per session
|
||||
* - Minimize/restore/permanently-close lifecycle for subagent windows
|
||||
* - Cross-browser state persistence (localStorage + server-backed PUT /api/subagent-window-states)
|
||||
* - Window state saved on every minimize/restore/close action
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.minimizedSubagents)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 9 of 9 — loaded last, after api-client.js
|
||||
*/
|
||||
|
||||
// Codeman — Subagent window management for CodemanApp
|
||||
// Loaded after app.js (needs CodemanApp class defined)
|
||||
|
||||
@@ -80,7 +100,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.saveSubagentWindowStates();
|
||||
},
|
||||
|
||||
// ========== Subagent Window State Persistence ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Window State Persistence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Save subagent window states (minimized/open) to server for cross-browser persistence.
|
||||
@@ -198,7 +220,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
// ========== Subagent Connection Lines ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Connection Lines
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Connection lines are drawn from agent windows to their parent TABs.
|
||||
// The parent TAB is determined by the PERSISTENT subagentParentMap.
|
||||
@@ -434,7 +458,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Subagent Floating Windows ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Floating Windows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
openSubagentWindow(agentId) {
|
||||
// If window already exists, focus it
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* @fileoverview Service worker for Web Push notifications.
|
||||
*
|
||||
* Receives push events from the Codeman server (via web-push library) and displays
|
||||
* OS-level notifications. Handles notification clicks to focus an existing Codeman
|
||||
* tab or open a new one. Supports action buttons, per-session deep linking, and
|
||||
* critical notification persistence (requireInteraction).
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest
|
||||
* service worker takes control immediately without waiting for tab refresh.
|
||||
*
|
||||
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
|
||||
* @see src/push-store.ts — server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
// Codeman Service Worker — Web Push notifications
|
||||
// This service worker receives push events from the server and displays OS-level notifications.
|
||||
// It also handles notification clicks to focus or open the Codeman tab.
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
/**
|
||||
* @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
||||
*
|
||||
* Defines two singleton objects:
|
||||
*
|
||||
* - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
|
||||
* Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
|
||||
* auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
|
||||
*
|
||||
* - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
|
||||
* again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
|
||||
* indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
|
||||
* (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
|
||||
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
|
||||
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
||||
*
|
||||
* @globals {object} DeepgramProvider
|
||||
* @globals {object} VoiceInput
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection for device checks)
|
||||
* @dependency app.js (uses global `app` for sendInput, showToast, terminal focus)
|
||||
* @loadorder 3 of 9 — loaded after mobile-handlers.js, before notification-manager.js
|
||||
*/
|
||||
|
||||
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Voice Input (Deepgram Nova-3 + Web Speech API fallback)
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
|
||||
@@ -131,7 +132,7 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev
|
||||
console.log(
|
||||
`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`
|
||||
);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: session.id,
|
||||
state: session.ralphTracker.loopState,
|
||||
});
|
||||
@@ -146,7 +147,7 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.startLoop(completionPhrase);
|
||||
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: session.id,
|
||||
state: session.ralphTracker.loopState,
|
||||
});
|
||||
|
||||
@@ -15,10 +15,15 @@ import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
// ============ Case CRUD ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Cases ==========
|
||||
|
||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
||||
const cases: CaseInfo[] = [];
|
||||
@@ -95,7 +100,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
ctx.broadcast('case:created', { name, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
|
||||
return { success: true, data: { case: { name, path: casePath } } };
|
||||
} catch (err) {
|
||||
@@ -152,7 +157,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast('case:linked', { name, path: expandedPath });
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -321,7 +326,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Ralph Wizard File Endpoints ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Wizard Files (per-case prompt/result serving)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Wizard Files ==========
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void {
|
||||
// ============ Plan Generation Endpoints ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Generation (simple AI + detailed orchestration)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Generate Plan (Simple) ==========
|
||||
|
||||
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
||||
@@ -215,8 +220,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Generate detailed implementation plan using subagent orchestration
|
||||
// This spawns multiple specialist subagents in parallel for thorough analysis
|
||||
// ========== Generate Plan (Detailed Orchestration) ==========
|
||||
|
||||
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
||||
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
||||
if (!gpdResult.success) {
|
||||
@@ -257,7 +262,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
ctx.activePlanOrchestrators.set(orchestratorId, orchestrator);
|
||||
|
||||
// Broadcast the orchestrator ID so frontend can cancel if needed
|
||||
ctx.broadcast('plan:started', { orchestratorId });
|
||||
ctx.broadcast(SseEvent.PlanStarted, { orchestratorId });
|
||||
|
||||
// Track progress for SSE updates
|
||||
const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = [];
|
||||
@@ -265,7 +270,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const update = { phase, detail, timestamp: Date.now() };
|
||||
progressUpdates.push(update);
|
||||
// Broadcast progress to connected clients
|
||||
ctx.broadcast('plan:progress', update);
|
||||
ctx.broadcast(SseEvent.PlanProgress, update);
|
||||
};
|
||||
|
||||
// Broadcast plan subagent events for UI visibility
|
||||
@@ -280,7 +285,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}) => {
|
||||
ctx.broadcast('plan:subagent', event);
|
||||
ctx.broadcast(SseEvent.PlanSubagent, event);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -292,7 +297,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Clean up orchestrator from active map
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:completed', { orchestratorId, success: result.success });
|
||||
ctx.broadcast(SseEvent.PlanCompleted, { orchestratorId, success: result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
|
||||
@@ -311,7 +316,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
} catch (err) {
|
||||
// Clean up on error too
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:completed', {
|
||||
ctx.broadcast(SseEvent.PlanCompleted, {
|
||||
orchestratorId,
|
||||
success: false,
|
||||
error: getErrorMessage(err),
|
||||
@@ -323,7 +328,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel active plan generation
|
||||
// ========== Cancel Plan Generation ==========
|
||||
|
||||
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
||||
const cpResult = CancelPlanSchema.safeParse(req.body);
|
||||
if (!cpResult.success) {
|
||||
@@ -340,7 +346,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[API] Cancelling plan generation ${orchestratorId}`);
|
||||
await orchestrator.cancel();
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:cancelled', { orchestratorId });
|
||||
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId });
|
||||
return { success: true, data: { cancelled: orchestratorId } };
|
||||
}
|
||||
|
||||
@@ -350,17 +356,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[API] Cancelling plan generation ${id}`);
|
||||
await orchestrator.cancel();
|
||||
cancelled.push(id);
|
||||
ctx.broadcast('plan:cancelled', { orchestratorId: id });
|
||||
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId: id });
|
||||
}
|
||||
ctx.activePlanOrchestrators.clear();
|
||||
|
||||
return { success: true, data: { cancelled } };
|
||||
});
|
||||
|
||||
// ============ Plan Management Endpoints ============
|
||||
// These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Management (task CRUD, checkpoints, version history, rollback)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Update Plan Task ==========
|
||||
|
||||
// Update a specific plan task (status, attempts, errors)
|
||||
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -385,11 +393,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
|
||||
}
|
||||
|
||||
ctx.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task });
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskUpdate, { sessionId: id, taskId, update: result.task });
|
||||
return { success: true, data: result.task };
|
||||
});
|
||||
|
||||
// Trigger a checkpoint review (at iterations 5, 10, 20, etc.)
|
||||
// ========== Create Checkpoint ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -400,11 +409,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
const checkpoint = tracker.generateCheckpointReview();
|
||||
ctx.broadcast('session:planCheckpoint', { sessionId: id, checkpoint });
|
||||
ctx.broadcast(SseEvent.SessionPlanCheckpoint, { sessionId: id, checkpoint });
|
||||
return { success: true, data: checkpoint };
|
||||
});
|
||||
|
||||
// Get plan version history
|
||||
// ========== Get Version History ==========
|
||||
|
||||
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -417,7 +427,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return { success: true, data: tracker.getPlanHistory() };
|
||||
});
|
||||
|
||||
// Rollback to a previous plan version
|
||||
// ========== Rollback to Version ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||
const { id, version } = req.params as { id: string; version: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -432,11 +443,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
|
||||
}
|
||||
|
||||
ctx.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) });
|
||||
ctx.broadcast(SseEvent.SessionPlanRollback, { sessionId: id, version: parseInt(version, 10) });
|
||||
return { success: true, data: result.plan };
|
||||
});
|
||||
|
||||
// Add a new task to the plan (for runtime adaptation)
|
||||
// ========== Add Plan Task ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -453,7 +465,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const task = ptaResult.data;
|
||||
|
||||
const result = tracker.addPlanTask(task);
|
||||
ctx.broadcast('session:planTaskAdded', { sessionId: id, task: result.task });
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
|
||||
return { success: true, data: result.task };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse }
|
||||
import { Session } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -23,6 +24,10 @@ export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Tracker Configuration & Status
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Configure Ralph tracker for a session
|
||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -95,7 +100,7 @@ export function registerRalphRoutes(
|
||||
|
||||
// Persist and broadcast the update
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: id,
|
||||
state: session.ralphLoopState,
|
||||
});
|
||||
@@ -136,6 +141,10 @@ export function registerRalphRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Fix Plan CRUD (@fix_plan.md generation, import, read/write)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -249,6 +258,10 @@ export function registerRalphRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Prompt & Loop (prompt write, loop start)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Write Ralph prompt to file in session's working directory
|
||||
// This avoids mux input escaping issues with long multi-line prompts
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
@@ -320,7 +333,7 @@ export function registerRalphRoutes(
|
||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
await writeHooksConfig(casePath);
|
||||
ctx.broadcast('case:created', { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -441,7 +454,7 @@ export function registerRalphRoutes(
|
||||
name: session.name,
|
||||
reason: 'ralph_loop_start',
|
||||
});
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
// Start interactive mode
|
||||
try {
|
||||
@@ -452,8 +465,8 @@ export function registerRalphRoutes(
|
||||
name: session.name,
|
||||
mode: 'claude',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode: 'claude' });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'claude' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
} catch (err) {
|
||||
await ctx.cleanupSession(session.id, true, 'ralph_loop_start_error');
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -475,7 +488,7 @@ export function registerRalphRoutes(
|
||||
controller.start();
|
||||
ctx.saveRespawnConfig(session.id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:started', {
|
||||
ctx.broadcast(SseEvent.RespawnStarted, {
|
||||
sessionId: session.id,
|
||||
status: controller.getStatus(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespawnConfig } from '../../types.js';
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -25,7 +26,12 @@ export function registerRespawnRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
// Get respawn status for a session
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Respawn Status & Config
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Get Respawn Status ==========
|
||||
|
||||
app.get('/api/sessions/:id/respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -40,7 +46,8 @@ export function registerRespawnRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// Get respawn config (from running controller or pre-saved)
|
||||
// ========== Get Respawn Config ==========
|
||||
|
||||
app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -58,7 +65,12 @@ export function registerRespawnRoutes(
|
||||
return { success: true, config: null, active: false };
|
||||
});
|
||||
|
||||
// Start respawn controller for a session
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Respawn Start & Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Start Respawn ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/start', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
let body: Partial<RespawnConfig> | undefined;
|
||||
@@ -95,12 +107,13 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return { success: true, status: controller.getStatus() };
|
||||
});
|
||||
|
||||
// Stop respawn controller for a session
|
||||
// ========== Stop Respawn ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -130,12 +143,13 @@ export function registerRespawnRoutes(
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
|
||||
ctx.broadcast('respawn:stopped', { sessionId: id });
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Update respawn configuration (works with or without running controller)
|
||||
// ========== Update Respawn Config ==========
|
||||
|
||||
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
@@ -153,7 +167,7 @@ export function registerRespawnRoutes(
|
||||
controller.updateConfig(config);
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() });
|
||||
return { success: true, config: controller.getConfig() };
|
||||
}
|
||||
|
||||
@@ -186,11 +200,16 @@ export function registerRespawnRoutes(
|
||||
};
|
||||
ctx.mux.updateRespawnConfig(id, merged);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged });
|
||||
return { success: true, config: merged };
|
||||
});
|
||||
|
||||
// Start interactive session WITH respawn enabled
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Composite Actions (interactive-respawn, enable on existing)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Interactive Respawn (start session + respawn in one call) ==========
|
||||
|
||||
app.post('/api/sessions/:id/interactive-respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||||
@@ -230,8 +249,8 @@ export function registerRespawnRoutes(
|
||||
mode: session.mode,
|
||||
reason: 'interactive_respawn',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Create and start respawn controller
|
||||
const controller = new RespawnController(session, body?.respawnConfig);
|
||||
@@ -247,7 +266,7 @@ export function registerRespawnRoutes(
|
||||
// Persist full session state with respawn config
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -261,7 +280,8 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Enable respawn on an EXISTING interactive session
|
||||
// ========== Enable Respawn on Existing Session ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/enable', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||||
@@ -304,7 +324,7 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SessionColor,
|
||||
} from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -54,6 +55,10 @@ export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Logout ==========
|
||||
|
||||
app.post('/api/logout', async (req, reply) => {
|
||||
@@ -66,6 +71,10 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session CRUD (list, create, rename, color, delete, detail)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Session Listing ==========
|
||||
|
||||
app.get('/api/sessions', async () => {
|
||||
@@ -146,7 +155,7 @@ export function registerSessionRoutes(
|
||||
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||||
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||||
const lightState = ctx.getSessionStateWithRespawn(session);
|
||||
ctx.broadcast('session:created', lightState);
|
||||
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
||||
return { success: true, session: lightState };
|
||||
});
|
||||
|
||||
@@ -170,7 +179,7 @@ export function registerSessionRoutes(
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
return { success: true, name: session.name };
|
||||
});
|
||||
|
||||
@@ -196,7 +205,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setColor(body.color as SessionColor);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
return { success: true, color: session.color };
|
||||
});
|
||||
|
||||
@@ -246,6 +255,10 @@ export function registerSessionRoutes(
|
||||
return ctx.getSessionStateWithRespawn(session);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Data (output, ralph state, run summary, active tools)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Get Session Output ==========
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
@@ -328,6 +341,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Execution (run prompt, interactive mode, shell mode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Run Prompt ==========
|
||||
|
||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||
@@ -349,10 +366,10 @@ export function registerSessionRoutes(
|
||||
|
||||
// Run async, don't wait
|
||||
session.runPrompt(prompt).catch((err) => {
|
||||
ctx.broadcast('session:error', { id, error: err.message });
|
||||
ctx.broadcast(SseEvent.SessionError, { id, error: err.message });
|
||||
});
|
||||
|
||||
ctx.broadcast('session:running', { id, prompt });
|
||||
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -391,8 +408,8 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: session.mode,
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
@@ -422,14 +439,18 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id, mode: 'shell' });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal I/O (input, resize, buffer)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Send Input ==========
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
@@ -550,6 +571,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Settings (auto-clear, auto-compact, image watcher, flicker filter)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Auto-Clear ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
@@ -567,7 +592,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -597,7 +622,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -661,7 +686,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -671,6 +696,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Quick Actions (quick-run, quick-start)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Quick Run ==========
|
||||
|
||||
app.post('/api/run', async (req) => {
|
||||
@@ -717,7 +746,7 @@ export function registerSessionRoutes(
|
||||
reason: 'run_prompt',
|
||||
});
|
||||
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
try {
|
||||
const result = await session.runPrompt(prompt);
|
||||
@@ -786,7 +815,7 @@ export function registerSessionRoutes(
|
||||
await writeHooksConfig(casePath);
|
||||
}
|
||||
|
||||
ctx.broadcast('case:created', { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -831,7 +860,7 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
reason: 'quick_start',
|
||||
});
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
// Start in the appropriate mode
|
||||
try {
|
||||
@@ -843,7 +872,7 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
||||
} else {
|
||||
// Both 'claude' and 'opencode' modes use startInteractive()
|
||||
await session.startInteractive();
|
||||
@@ -853,9 +882,9 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode,
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
||||
}
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
@@ -91,6 +92,10 @@ export function registerSystemRoutes(
|
||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// System Status & Health
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
app.get('/api/status', async () => ctx.getLightState());
|
||||
@@ -120,6 +125,10 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Authentication (QR auth, session revocation)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== QR Auth Route ==========
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
@@ -177,7 +186,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// Broadcast auth notification — desktop sees who authenticated
|
||||
ctx.broadcast('tunnel:qrAuthUsed', {
|
||||
ctx.broadcast(SseEvent.TunnelQrAuthUsed, {
|
||||
ip: clientIp,
|
||||
ua: clientUA,
|
||||
timestamp: Date.now(),
|
||||
@@ -206,6 +215,10 @@ export function registerSystemRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== OpenCode ==========
|
||||
|
||||
app.get('/api/opencode/status', async () => {
|
||||
@@ -216,6 +229,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== State & Lifecycle ==========
|
||||
|
||||
app.post('/api/cleanup-state', async () => {
|
||||
@@ -279,6 +296,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration & Settings (config, settings, model config, CPU priority)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Config ==========
|
||||
|
||||
app.get('/api/config', async () => {
|
||||
@@ -424,7 +445,7 @@ export function registerSystemRoutes(
|
||||
console.log('Tunnel started via settings change');
|
||||
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
|
||||
// Tunnel already running — re-emit so the client gets the URL
|
||||
ctx.broadcast('tunnel:started', { url: ctx.tunnelManager.getUrl() });
|
||||
ctx.broadcast(SseEvent.TunnelStarted, { url: ctx.tunnelManager.getUrl() });
|
||||
console.log('Tunnel already running, re-broadcast URL to client');
|
||||
} else if (!tunnelEnabled && ctx.tunnelManager.isRunning()) {
|
||||
ctx.tunnelManager.stop();
|
||||
@@ -506,7 +527,7 @@ export function registerSystemRoutes(
|
||||
|
||||
session.setNice(body);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -515,6 +536,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Management (window states, parents, monitoring, transcripts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Subagent Window State Persistence ==========
|
||||
|
||||
app.get('/api/subagent-window-states', async () => {
|
||||
@@ -643,6 +668,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Screenshots (upload, list, serve)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Screenshots ==========
|
||||
|
||||
app.post('/api/screenshots', async (req, reply) => {
|
||||
|
||||
+214
-143
@@ -1,11 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Codeman web server and REST API
|
||||
* @fileoverview Codeman web server — central hub coordinating all subsystems.
|
||||
*
|
||||
* Provides a Fastify-based web server with:
|
||||
* - REST API for session management, respawn control, and monitoring
|
||||
* - Server-Sent Events (SSE) for real-time updates at /api/events
|
||||
* - Static file serving for the web UI
|
||||
* - 60fps terminal streaming with batched updates
|
||||
* Fastify-based web server providing:
|
||||
* - ~111 REST API routes (delegated to `src/web/routes/` domain modules)
|
||||
* - SSE streaming at `/api/events` with backpressure handling
|
||||
* - Static file serving for the web UI (1-year cache in production)
|
||||
* - 60fps terminal streaming via batched PTY output (16-50ms adaptive)
|
||||
*
|
||||
* Coordinates: SessionManager, RespawnController, SubagentWatcher, TeamWatcher,
|
||||
* TranscriptWatcher, ImageWatcher, TunnelManager, PushSubscriptionStore,
|
||||
* PlanOrchestrator, RunSummaryTracker, FileStreamManager.
|
||||
*
|
||||
* Key exports:
|
||||
* - `WebServer` class — implements all port interfaces, extends EventEmitter
|
||||
* - `startWebServer(options)` — factory function to create and start the server
|
||||
*
|
||||
* Implements port interfaces: `SessionPort`, `EventPort`, `ConfigPort`,
|
||||
* `RespawnPort`, `MuxPort`, `FilePort`, `ScheduledPort`, `PushPort`, `TeamPort`
|
||||
* (see `src/web/ports/` for definitions)
|
||||
*
|
||||
* @dependencies All major subsystems (session, respawn-controller, subagent-watcher,
|
||||
* team-watcher, tunnel-manager, state-store, etc.)
|
||||
* @consumedby src/index.ts (entry point), src/cli.ts
|
||||
* @emits SSE events via broadcast() — see sse-events.ts for full registry
|
||||
*
|
||||
* @module web/server
|
||||
*/
|
||||
@@ -70,6 +87,7 @@ import {
|
||||
} from '../types.js';
|
||||
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
||||
import {
|
||||
@@ -280,10 +298,10 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up mux event listeners
|
||||
this.mux.on('sessionCreated', (session) => {
|
||||
this.broadcast('mux:created', session);
|
||||
this.broadcast(SseEvent.MuxCreated, session);
|
||||
});
|
||||
this.mux.on('sessionKilled', (data) => {
|
||||
this.broadcast('mux:killed', data);
|
||||
this.broadcast(SseEvent.MuxKilled, data);
|
||||
});
|
||||
this.mux.on('sessionDied', (data) => {
|
||||
getLifecycleLog().log({
|
||||
@@ -291,10 +309,10 @@ export class WebServer extends EventEmitter {
|
||||
sessionId: (data as { sessionId?: string }).sessionId || 'unknown',
|
||||
extra: data as Record<string, unknown>,
|
||||
});
|
||||
this.broadcast('mux:died', data);
|
||||
this.broadcast(SseEvent.MuxDied, data);
|
||||
});
|
||||
this.mux.on('statsUpdated', (sessions) => {
|
||||
this.broadcast('mux:statsUpdated', sessions);
|
||||
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
||||
});
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
@@ -308,16 +326,16 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up tunnel manager listeners
|
||||
this.tunnelManager.on('started', (data: { url: string }) => {
|
||||
this.broadcast('tunnel:started', data);
|
||||
this.broadcast(SseEvent.TunnelStarted, data);
|
||||
});
|
||||
this.tunnelManager.on('stopped', () => {
|
||||
this.broadcast('tunnel:stopped', {});
|
||||
this.broadcast(SseEvent.TunnelStopped, {});
|
||||
});
|
||||
this.tunnelManager.on('error', (message: string) => {
|
||||
this.broadcast('tunnel:error', { message });
|
||||
this.broadcast(SseEvent.TunnelError, { message });
|
||||
});
|
||||
this.tunnelManager.on('progress', (data: { message: string }) => {
|
||||
this.broadcast('tunnel:progress', data);
|
||||
this.broadcast(SseEvent.TunnelProgress, data);
|
||||
});
|
||||
|
||||
// QR token rotation — broadcast inline SVG for instant desktop refresh
|
||||
@@ -326,7 +344,7 @@ export class WebServer extends EventEmitter {
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRotated', { svg });
|
||||
this.broadcast(SseEvent.TunnelQrRotated, { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip this rotation
|
||||
}
|
||||
@@ -338,7 +356,7 @@ export class WebServer extends EventEmitter {
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRegenerated', { svg });
|
||||
this.broadcast(SseEvent.TunnelQrRegenerated, { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip
|
||||
}
|
||||
@@ -357,13 +375,13 @@ export class WebServer extends EventEmitter {
|
||||
private setupSubagentWatcherListeners(): void {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.subagentWatcherHandlers = {
|
||||
discovered: (info: SubagentInfo) => this.broadcast('subagent:discovered', info),
|
||||
updated: (info: SubagentInfo) => this.broadcast('subagent:updated', info),
|
||||
toolCall: (data: SubagentToolCall) => this.broadcast('subagent:tool_call', data),
|
||||
toolResult: (data: SubagentToolResult) => this.broadcast('subagent:tool_result', data),
|
||||
progress: (data: SubagentProgress) => this.broadcast('subagent:progress', data),
|
||||
message: (data: SubagentMessage) => this.broadcast('subagent:message', data),
|
||||
completed: (info: SubagentInfo) => this.broadcast('subagent:completed', info),
|
||||
discovered: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentDiscovered, info),
|
||||
updated: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentUpdated, info),
|
||||
toolCall: (data: SubagentToolCall) => this.broadcast(SseEvent.SubagentToolCall, data),
|
||||
toolResult: (data: SubagentToolResult) => this.broadcast(SseEvent.SubagentToolResult, data),
|
||||
progress: (data: SubagentProgress) => this.broadcast(SseEvent.SubagentProgress, data),
|
||||
message: (data: SubagentMessage) => this.broadcast(SseEvent.SubagentMessage, data),
|
||||
completed: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentCompleted, info),
|
||||
error: (error: Error, agentId?: string) => {
|
||||
console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
|
||||
},
|
||||
@@ -403,7 +421,7 @@ export class WebServer extends EventEmitter {
|
||||
private setupImageWatcherListeners(): void {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.imageWatcherHandlers = {
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast('image:detected', event),
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||
error: (error: Error, sessionId?: string) => {
|
||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||
},
|
||||
@@ -430,10 +448,10 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private setupTeamWatcherListeners(): void {
|
||||
this.teamWatcherHandlers = {
|
||||
teamCreated: (config: unknown) => this.broadcast('team:created', config),
|
||||
teamUpdated: (config: unknown) => this.broadcast('team:updated', config),
|
||||
teamRemoved: (config: unknown) => this.broadcast('team:removed', config),
|
||||
taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data),
|
||||
teamCreated: (config: unknown) => this.broadcast(SseEvent.TeamCreated, config),
|
||||
teamUpdated: (config: unknown) => this.broadcast(SseEvent.TeamUpdated, config),
|
||||
teamRemoved: (config: unknown) => this.broadcast(SseEvent.TeamRemoved, config),
|
||||
taskUpdated: (data: unknown) => this.broadcast(SseEvent.TeamTaskUpdated, data),
|
||||
};
|
||||
|
||||
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
||||
@@ -580,7 +598,7 @@ export class WebServer extends EventEmitter {
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
this.sendSSE(reply, 'init', this.getLightState());
|
||||
this.sendSSE(reply, SseEvent.Init, this.getLightState());
|
||||
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
||||
// (and any immediately following events) are delivered without proxy delay.
|
||||
if (this.tunnelManager.getUrl()) {
|
||||
@@ -640,7 +658,7 @@ export class WebServer extends EventEmitter {
|
||||
if (controller) {
|
||||
controller.signalTranscriptComplete();
|
||||
}
|
||||
this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() });
|
||||
this.broadcast(SseEvent.TranscriptComplete, { sessionId, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
watcher.on('transcript:plan_mode', () => {
|
||||
@@ -648,15 +666,15 @@ export class WebServer extends EventEmitter {
|
||||
if (controller) {
|
||||
controller.signalTranscriptPlanMode();
|
||||
}
|
||||
this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() });
|
||||
this.broadcast(SseEvent.TranscriptPlanMode, { sessionId, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
watcher.on('transcript:tool_start', (toolName: string) => {
|
||||
this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() });
|
||||
this.broadcast(SseEvent.TranscriptToolStart, { sessionId, toolName, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => {
|
||||
this.broadcast('transcript:tool_end', {
|
||||
this.broadcast(SseEvent.TranscriptToolEnd, {
|
||||
sessionId,
|
||||
toolName,
|
||||
isError,
|
||||
@@ -806,7 +824,7 @@ export class WebServer extends EventEmitter {
|
||||
controller.removeAllListeners();
|
||||
this.respawnControllers.delete(sessionId);
|
||||
// Notify UI that respawn is stopped for this session
|
||||
this.broadcast('respawn:stopped', { sessionId, reason: 'session_cleanup' });
|
||||
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'session_cleanup' });
|
||||
}
|
||||
|
||||
// Clear respawn timer
|
||||
@@ -858,7 +876,7 @@ export class WebServer extends EventEmitter {
|
||||
this.store.removeRalphState(sessionId);
|
||||
|
||||
// Broadcast Ralph cleared to update UI
|
||||
this.broadcast('session:ralphLoopUpdate', {
|
||||
this.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId,
|
||||
state: {
|
||||
enabled: false,
|
||||
@@ -871,7 +889,7 @@ export class WebServer extends EventEmitter {
|
||||
elapsedHours: null,
|
||||
},
|
||||
});
|
||||
this.broadcast('session:ralphTodoUpdate', {
|
||||
this.broadcast(SseEvent.SessionRalphTodoUpdate, {
|
||||
sessionId,
|
||||
todos: [],
|
||||
stats: { total: 0, pending: 0, inProgress: 0, completed: 0 },
|
||||
@@ -941,7 +959,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcast('session:deleted', { id: sessionId });
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
@@ -960,49 +978,58 @@ export class WebServer extends EventEmitter {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
|
||||
// Store all listener references for explicit cleanup on session delete
|
||||
// This prevents memory leaks from closure references keeping objects alive
|
||||
// Store all listener references for explicit cleanup on session delete.
|
||||
// This prevents memory leaks from closure references keeping objects alive.
|
||||
const listeners: SessionListenerRefs = {
|
||||
// ─── Terminal Output ─────────────────────────────────────
|
||||
// These listeners handle raw PTY output streaming to SSE clients.
|
||||
|
||||
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
|
||||
terminal: (data) => {
|
||||
// Use batching for better performance at high throughput
|
||||
this.batchTerminalData(session.id, data);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
|
||||
clearTerminal: () => {
|
||||
// Tell clients to clear their terminal (after mux attach)
|
||||
this.broadcast('session:clearTerminal', { id: session.id });
|
||||
this.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer (e.g., after OpenCode TUI stabilizes) */
|
||||
needsRefresh: () => {
|
||||
// Tell clients to reload the terminal buffer (e.g., after OpenCode TUI stabilizes)
|
||||
this.broadcast('session:needsRefresh', { id: session.id });
|
||||
this.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
|
||||
},
|
||||
|
||||
// ─── Session Messages & Errors ──────────────────────────
|
||||
|
||||
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
|
||||
message: (msg: ClaudeMessage) => {
|
||||
this.broadcast('session:message', { id: session.id, message: msg });
|
||||
this.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:error` + sends push notification */
|
||||
error: (error) => {
|
||||
this.broadcast('session:error', { id: session.id, error });
|
||||
this.sendPushNotifications('session:error', {
|
||||
this.broadcast(SseEvent.SessionError, { id: session.id, error });
|
||||
this.sendPushNotifications(SseEvent.SessionError, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
error: String(error),
|
||||
});
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordError('Session error', String(error));
|
||||
},
|
||||
|
||||
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
|
||||
completion: (result, cost) => {
|
||||
this.broadcast('session:completion', { id: session.id, result, cost });
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
|
||||
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
||||
this.persistSessionState(session);
|
||||
// Track tokens in run summary (completion event has updated token values)
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
},
|
||||
|
||||
// ─── Session Lifecycle ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
||||
exit: (code) => {
|
||||
getLifecycleLog().log({
|
||||
event: 'exit',
|
||||
@@ -1012,8 +1039,8 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
// Wrap in try/catch to ensure cleanup always happens
|
||||
try {
|
||||
this.broadcast('session:exit', { id: session.id, code });
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
this.broadcast(SseEvent.SessionExit, { id: session.id, code });
|
||||
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
||||
this.persistSessionState(session);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
|
||||
@@ -1107,9 +1134,11 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Activity State ─────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:working` — Claude started processing */
|
||||
working: () => {
|
||||
this.broadcast('session:working', { id: session.id });
|
||||
// Track in run summary
|
||||
this.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
@@ -1117,11 +1146,10 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
this.broadcast('session:idle', { id: session.id });
|
||||
// Use debounced state update (idle can fire frequently)
|
||||
this.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
@@ -1129,78 +1157,87 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
// Background task events - use debounced state updates to reduce serialization overhead
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
// Debounced state updates to reduce serialization overhead.
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
taskCreated: (task: BackgroundTask) => {
|
||||
this.broadcast('task:created', { sessionId: session.id, task });
|
||||
this.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Batched broadcast of `task:updated` — high-frequency progress updates */
|
||||
taskUpdated: (task: BackgroundTask) => {
|
||||
// Use batching for better performance at high update rates
|
||||
this.batchTaskUpdate(session.id, task);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:completed` — background task finished successfully */
|
||||
taskCompleted: (task: BackgroundTask) => {
|
||||
this.broadcast('task:completed', { sessionId: session.id, task });
|
||||
this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:failed` — background task errored */
|
||||
taskFailed: (task: BackgroundTask, error: string) => {
|
||||
this.broadcast('task:failed', { sessionId: session.id, task, error });
|
||||
this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Auto-Operations ────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => {
|
||||
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
|
||||
this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||||
this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
|
||||
this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
// Claude Code CLI info parsed from terminal (version, model, account)
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
|
||||
this.broadcast('session:cliInfo', { sessionId: session.id, ...data });
|
||||
this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// Ralph tracking events
|
||||
// ─── Ralph Tracking Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => {
|
||||
this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state });
|
||||
// Persist Ralph state
|
||||
this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
|
||||
this.store.updateRalphState(session.id, { loop: state });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
|
||||
this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos });
|
||||
// Persist Ralph state
|
||||
this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
|
||||
this.store.updateRalphState(session.id, { todos });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
|
||||
ralphCompletionDetected: (phrase: string) => {
|
||||
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
|
||||
this.sendPushNotifications('session:ralphCompletionDetected', {
|
||||
this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
|
||||
this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
phrase,
|
||||
});
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordRalphCompletion(phrase);
|
||||
},
|
||||
|
||||
// RALPH_STATUS block events
|
||||
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
|
||||
ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => {
|
||||
this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block });
|
||||
// Track in run summary
|
||||
this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
@@ -1212,18 +1249,18 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
|
||||
ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => {
|
||||
this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status });
|
||||
// Track state changes in run summary
|
||||
this.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker && status.state === 'OPEN') {
|
||||
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
|
||||
this.broadcast('session:exitGateMet', { sessionId: session.id, ...data });
|
||||
// Track in run summary
|
||||
this.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
@@ -1235,17 +1272,22 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
// Bash tool tracking events (for clickable file paths)
|
||||
// ─── Bash Tool Tracking ────────────────────────────────
|
||||
// Used for clickable file paths in the UI.
|
||||
|
||||
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
|
||||
bashToolStart: (tool: ActiveBashTool) => {
|
||||
this.broadcast('session:bashToolStart', { sessionId: session.id, tool });
|
||||
this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
|
||||
bashToolEnd: (tool: ActiveBashTool) => {
|
||||
this.broadcast('session:bashToolEnd', { sessionId: session.id, tool });
|
||||
this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||
this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools });
|
||||
this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1287,83 +1329,103 @@ export class WebServer extends EventEmitter {
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||||
|
||||
// ─── Respawn State Machine ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
|
||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
||||
// Track in run summary (lazy lookup since tracker may be created after controller)
|
||||
this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
|
||||
});
|
||||
|
||||
// ─── Respawn Cycle Lifecycle ────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
|
||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||
this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber });
|
||||
this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
|
||||
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
|
||||
this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber });
|
||||
this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
|
||||
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
|
||||
this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details });
|
||||
this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
|
||||
const sessionForPush = this.sessions.get(sessionId);
|
||||
this.sendPushNotifications('respawn:blocked', {
|
||||
this.sendPushNotifications(SseEvent.RespawnBlocked, {
|
||||
sessionId,
|
||||
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
||||
reason: data.reason,
|
||||
});
|
||||
// Track in run summary (lazy lookup)
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
||||
});
|
||||
|
||||
// ─── Respawn Step Progress ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
|
||||
controller.on('stepSent', (step: string, input: string) => {
|
||||
this.broadcast('respawn:stepSent', { sessionId, step, input });
|
||||
this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
|
||||
controller.on('stepCompleted', (step: string) => {
|
||||
this.broadcast('respawn:stepCompleted', { sessionId, step });
|
||||
this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
|
||||
controller.on('detectionUpdate', (detection: unknown) => {
|
||||
this.broadcast('respawn:detectionUpdate', { sessionId, detection });
|
||||
this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
|
||||
controller.on('autoAcceptSent', () => {
|
||||
this.broadcast('respawn:autoAcceptSent', { sessionId });
|
||||
this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
|
||||
});
|
||||
|
||||
// ─── AI Checker Events ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
|
||||
controller.on('aiCheckStarted', () => {
|
||||
this.broadcast('respawn:aiCheckStarted', { sessionId });
|
||||
this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
|
||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
this.broadcast('respawn:aiCheckCompleted', {
|
||||
this.broadcast(SseEvent.RespawnAiCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
// Track in run summary (lazy lookup)
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordAiCheckResult(result.verdict);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
|
||||
controller.on('aiCheckFailed', (error: string) => {
|
||||
this.broadcast('respawn:aiCheckFailed', { sessionId, error });
|
||||
// Track in run summary (lazy lookup)
|
||||
this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('AI check failed', error);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
|
||||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||
this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt });
|
||||
this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
|
||||
});
|
||||
|
||||
// ─── Plan Checker Events ────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
|
||||
controller.on('planCheckStarted', () => {
|
||||
this.broadcast('respawn:planCheckStarted', { sessionId });
|
||||
this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
|
||||
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
this.broadcast('respawn:planCheckCompleted', {
|
||||
this.broadcast(SseEvent.RespawnPlanCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
@@ -1371,34 +1433,43 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
|
||||
controller.on('planCheckFailed', (error: string) => {
|
||||
this.broadcast('respawn:planCheckFailed', { sessionId, error });
|
||||
this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
|
||||
});
|
||||
|
||||
// Timer tracking events for UI countdown display
|
||||
// ─── Timer Events (UI countdown display) ────────────────
|
||||
|
||||
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
|
||||
controller.on('timerStarted', (timer) => {
|
||||
this.broadcast('respawn:timerStarted', { sessionId, timer });
|
||||
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
|
||||
controller.on('timerCancelled', (timerName, reason) => {
|
||||
this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason });
|
||||
this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCompleted` — timer expired */
|
||||
controller.on('timerCompleted', (timerName) => {
|
||||
this.broadcast('respawn:timerCompleted', { sessionId, timerName });
|
||||
this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
|
||||
});
|
||||
|
||||
// ─── Logging & Errors ───────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
|
||||
controller.on('actionLog', (action) => {
|
||||
this.broadcast('respawn:actionLog', { sessionId, action });
|
||||
this.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:log` — general respawn log message */
|
||||
controller.on('log', (message: string) => {
|
||||
this.broadcast('respawn:log', { sessionId, message });
|
||||
this.broadcast(SseEvent.RespawnLog, { sessionId, message });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:error` — respawn controller error */
|
||||
controller.on('error', (error: Error) => {
|
||||
this.broadcast('respawn:error', { sessionId, error: error.message });
|
||||
// Track in run summary (lazy lookup)
|
||||
this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('Respawn error', error.message);
|
||||
});
|
||||
@@ -1422,7 +1493,7 @@ export class WebServer extends EventEmitter {
|
||||
controller.stop();
|
||||
controller.removeAllListeners();
|
||||
this.respawnControllers.delete(sessionId);
|
||||
this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' });
|
||||
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
|
||||
}
|
||||
this.respawnTimers.delete(sessionId);
|
||||
// Update persisted state (respawn no longer active)
|
||||
@@ -1435,7 +1506,7 @@ export class WebServer extends EventEmitter {
|
||||
);
|
||||
|
||||
this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
|
||||
this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now });
|
||||
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1494,7 +1565,7 @@ export class WebServer extends EventEmitter {
|
||||
const ctrl = this.respawnControllers.get(session.id);
|
||||
if (ctrl && ctrl.state === 'stopped') {
|
||||
ctrl.start();
|
||||
this.broadcast('respawn:started', { sessionId: session.id });
|
||||
this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
|
||||
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
||||
}
|
||||
}, delayMs);
|
||||
@@ -1607,7 +1678,7 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
|
||||
this.scheduledRuns.set(id, run);
|
||||
this.broadcast('scheduled:created', run);
|
||||
this.broadcast(SseEvent.ScheduledCreated, run);
|
||||
|
||||
// Start the run loop (fire-and-forget with error handling)
|
||||
this.runScheduledLoop(id).catch((err) => {
|
||||
@@ -1616,7 +1687,7 @@ export class WebServer extends EventEmitter {
|
||||
if (failedRun && failedRun.status === 'running') {
|
||||
failedRun.status = 'stopped';
|
||||
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.broadcast('scheduled:stopped', { id, reason: 'error' });
|
||||
this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1629,7 +1700,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
const addLog = (msg: string) => {
|
||||
run.logs.push(`[${new Date().toISOString()}] ${msg}`);
|
||||
this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] });
|
||||
this.broadcast(SseEvent.ScheduledLog, { id: runId, log: run.logs[run.logs.length - 1] });
|
||||
};
|
||||
|
||||
while (Date.now() < run.endAt && run.status === 'running') {
|
||||
@@ -1651,7 +1722,7 @@ export class WebServer extends EventEmitter {
|
||||
run.sessionId = session.id;
|
||||
|
||||
addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`);
|
||||
this.broadcast('scheduled:updated', run);
|
||||
this.broadcast(SseEvent.ScheduledUpdated, run);
|
||||
|
||||
// Run the prompt
|
||||
const timeRemaining = Math.round((run.endAt - Date.now()) / 60000);
|
||||
@@ -1662,7 +1733,7 @@ export class WebServer extends EventEmitter {
|
||||
run.totalCost += result.cost;
|
||||
|
||||
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
|
||||
this.broadcast('scheduled:updated', run);
|
||||
this.broadcast(SseEvent.ScheduledUpdated, run);
|
||||
|
||||
// Clean up the session after iteration to prevent memory leaks
|
||||
await this.cleanupSession(session.id, true, 'scheduled_run');
|
||||
@@ -1672,7 +1743,7 @@ export class WebServer extends EventEmitter {
|
||||
await new Promise((r) => setTimeout(r, ITERATION_PAUSE_MS));
|
||||
} catch (err) {
|
||||
addLog(`Error: ${getErrorMessage(err)}`);
|
||||
this.broadcast('scheduled:updated', run);
|
||||
this.broadcast(SseEvent.ScheduledUpdated, run);
|
||||
|
||||
// Clean up the session on error too
|
||||
if (session) {
|
||||
@@ -1694,7 +1765,7 @@ export class WebServer extends EventEmitter {
|
||||
addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`);
|
||||
}
|
||||
|
||||
this.broadcast('scheduled:completed', run);
|
||||
this.broadcast(SseEvent.ScheduledCompleted, run);
|
||||
}
|
||||
|
||||
private async stopScheduledRun(id: string): Promise<void> {
|
||||
@@ -1710,7 +1781,7 @@ export class WebServer extends EventEmitter {
|
||||
run.sessionId = null;
|
||||
}
|
||||
|
||||
this.broadcast('scheduled:stopped', run);
|
||||
this.broadcast(SseEvent.ScheduledStopped, run);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1761,7 +1832,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
for (const id of toDelete) {
|
||||
this.scheduledRuns.delete(id);
|
||||
this.broadcast('scheduled:deleted', { id });
|
||||
this.broadcast(SseEvent.ScheduledDeleted, { id });
|
||||
}
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
@@ -1847,7 +1918,7 @@ export class WebServer extends EventEmitter {
|
||||
// Client may have missed terminal data during backpressure.
|
||||
// Tell it to reload the active session's buffer to recover.
|
||||
try {
|
||||
reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`);
|
||||
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n`);
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
@@ -1867,7 +1938,7 @@ export class WebServer extends EventEmitter {
|
||||
// 1. The debounced session:updated follows within 500ms with the new state
|
||||
// 2. These caches serve /api/sessions and SSE init — neither is polled rapidly
|
||||
// 3. Invalidating on every working/idle transition makes the 1s TTL useless
|
||||
if (event === 'session:created' || event === 'session:deleted' || event === 'session:updated') {
|
||||
if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted || event === SseEvent.SessionUpdated) {
|
||||
this.cachedLightState = null;
|
||||
this.cachedSessionsList = null;
|
||||
}
|
||||
@@ -2004,7 +2075,7 @@ export class WebServer extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||||
this.broadcast('task:updated', { sessionId, task });
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
}
|
||||
@@ -2042,7 +2113,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
// Single expensive serialization per batch interval
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
||||
}
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
@@ -2055,7 +2126,7 @@ export class WebServer extends EventEmitter {
|
||||
string,
|
||||
{ title: string; urgency: string; actions?: Array<{ action: string; title: string }> }
|
||||
> = {
|
||||
'hook:permission_prompt': {
|
||||
[SseEvent.HookPermissionPrompt]: {
|
||||
title: 'Permission Required',
|
||||
urgency: 'critical',
|
||||
actions: [
|
||||
@@ -2063,12 +2134,12 @@ export class WebServer extends EventEmitter {
|
||||
{ action: 'deny', title: 'Deny' },
|
||||
],
|
||||
},
|
||||
'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' },
|
||||
'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' },
|
||||
'hook:stop': { title: 'Response Complete', urgency: 'info' },
|
||||
'session:error': { title: 'Session Error', urgency: 'critical' },
|
||||
'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' },
|
||||
'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' },
|
||||
[SseEvent.HookElicitationDialog]: { title: 'Question Asked', urgency: 'critical' },
|
||||
[SseEvent.HookIdlePrompt]: { title: 'Waiting for Input', urgency: 'warning' },
|
||||
[SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
|
||||
[SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
|
||||
[SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
|
||||
[SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2091,16 +2162,16 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === 'session:error' && data.error) {
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === 'respawn:blocked' && data.reason) {
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === 'session:ralphCompletionDetected' && data.phrase) {
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === 'hook:permission_prompt' && data.tool_name) {
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @fileoverview Centralized SSE event type registry — single source of truth.
|
||||
*
|
||||
* All Server-Sent Event type strings used by the backend (`broadcast()` calls)
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* ~90 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
* - **Tasks** (4): created, completed, failed, updated
|
||||
* - **Mux** (4): created, killed, died, statsUpdated
|
||||
* - **Respawn** (17): stateChanged, cycleStarted, aiCheck*, timer*, log, ...
|
||||
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
|
||||
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
|
||||
* - **Teams** (4): created, updated, removed, taskUpdated
|
||||
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
|
||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image** (1): detected
|
||||
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
|
||||
* - **Cases** (2): created, linked
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
* Key export: `SseEvent` namespace object — import for destructured access.
|
||||
*
|
||||
* Usage:
|
||||
* import { SseEvent } from './sse-events.js';
|
||||
* ctx.broadcast(SseEvent.SessionCreated, { id: session.id });
|
||||
*
|
||||
* When adding a new event:
|
||||
* 1. Add the constant here with JSDoc
|
||||
* 2. Add the matching entry in `src/web/public/constants.js` SSE_EVENTS object
|
||||
* 3. Add the frontend listener in the appropriate `addListener()` call
|
||||
*/
|
||||
|
||||
// ─── Core ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sent to each SSE client on initial connection with full app state. */
|
||||
export const Init = 'init' as const;
|
||||
|
||||
// ─── Session Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/** New session spawned. */
|
||||
export const SessionCreated = 'session:created' as const;
|
||||
/** Session state changed (status, config, tokens, etc.). */
|
||||
export const SessionUpdated = 'session:updated' as const;
|
||||
/** Session permanently removed. */
|
||||
export const SessionDeleted = 'session:deleted' as const;
|
||||
/** Raw PTY terminal output chunk. */
|
||||
export const SessionTerminal = 'session:terminal' as const;
|
||||
/** Client should re-fetch the full terminal buffer (e.g. after reconnect). */
|
||||
export const SessionNeedsRefresh = 'session:needsRefresh' as const;
|
||||
/** Terminal buffer cleared (e.g. /clear command). */
|
||||
export const SessionClearTerminal = 'session:clearTerminal' as const;
|
||||
/** Claude finished a prompt — includes result and cost. */
|
||||
export const SessionCompletion = 'session:completion' as const;
|
||||
/** Session-level error. */
|
||||
export const SessionError = 'session:error' as const;
|
||||
/** Claude CLI process exited. */
|
||||
export const SessionExit = 'session:exit' as const;
|
||||
/** Session transitioned to idle (waiting for input). */
|
||||
export const SessionIdle = 'session:idle' as const;
|
||||
/** Session transitioned to working (Claude is processing). */
|
||||
export const SessionWorking = 'session:working' as const;
|
||||
/** Auto-clear triggered for the session. */
|
||||
export const SessionAutoClear = 'session:autoClear' as const;
|
||||
/** Auto-compact triggered for the session. */
|
||||
export const SessionAutoCompact = 'session:autoCompact' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
export const SessionMessage = 'session:message' as const;
|
||||
/** Session entered interactive mode (claude or shell). */
|
||||
export const SessionInteractive = 'session:interactive' as const;
|
||||
/** Prompt sent to session for execution. */
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Ralph loop state changed (enabled/disabled, iteration count). */
|
||||
export const SessionRalphLoopUpdate = 'session:ralphLoopUpdate' as const;
|
||||
/** Ralph todo items updated. */
|
||||
export const SessionRalphTodoUpdate = 'session:ralphTodoUpdate' as const;
|
||||
/** Ralph completion phrase detected in output. */
|
||||
export const SessionRalphCompletionDetected = 'session:ralphCompletionDetected' as const;
|
||||
/** Ralph status block parsed from output. */
|
||||
export const SessionRalphStatusUpdate = 'session:ralphStatusUpdate' as const;
|
||||
/** Circuit breaker state changed (CLOSED/HALF_OPEN/OPEN). */
|
||||
export const SessionCircuitBreakerUpdate = 'session:circuitBreakerUpdate' as const;
|
||||
/** Exit gate condition met (e.g. completion phrase found). */
|
||||
export const SessionExitGateMet = 'session:exitGateMet' as const;
|
||||
|
||||
// ─── Session: Bash Tools ─────────────────────────────────────────────────────
|
||||
|
||||
/** Bash tool invocation started. */
|
||||
export const SessionBashToolStart = 'session:bashToolStart' as const;
|
||||
/** Bash tool invocation completed. */
|
||||
export const SessionBashToolEnd = 'session:bashToolEnd' as const;
|
||||
/** Active bash tools list changed. */
|
||||
export const SessionBashToolsUpdate = 'session:bashToolsUpdate' as const;
|
||||
|
||||
// ─── Session: Plan ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Plan task status updated. */
|
||||
export const SessionPlanTaskUpdate = 'session:planTaskUpdate' as const;
|
||||
/** Plan checkpoint created. */
|
||||
export const SessionPlanCheckpoint = 'session:planCheckpoint' as const;
|
||||
/** Plan rolled back to a previous version. */
|
||||
export const SessionPlanRollback = 'session:planRollback' as const;
|
||||
/** New task added to plan. */
|
||||
export const SessionPlanTaskAdded = 'session:planTaskAdded' as const;
|
||||
|
||||
// ─── Tasks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Background task created. */
|
||||
export const TaskCreated = 'task:created' as const;
|
||||
/** Background task completed successfully. */
|
||||
export const TaskCompleted = 'task:completed' as const;
|
||||
/** Background task failed. */
|
||||
export const TaskFailed = 'task:failed' as const;
|
||||
/** Background task state updated. */
|
||||
export const TaskUpdated = 'task:updated' as const;
|
||||
|
||||
// ─── Mux (tmux) ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** tmux session created. */
|
||||
export const MuxCreated = 'mux:created' as const;
|
||||
/** tmux session killed. */
|
||||
export const MuxKilled = 'mux:killed' as const;
|
||||
/** tmux session died unexpectedly. */
|
||||
export const MuxDied = 'mux:died' as const;
|
||||
/** tmux session stats refreshed. */
|
||||
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
|
||||
|
||||
// ─── Respawn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Respawn loop started for a session. */
|
||||
export const RespawnStarted = 'respawn:started' as const;
|
||||
/** Respawn loop stopped. */
|
||||
export const RespawnStopped = 'respawn:stopped' as const;
|
||||
/** Respawn state machine transitioned. */
|
||||
export const RespawnStateChanged = 'respawn:stateChanged' as const;
|
||||
/** New respawn cycle started. */
|
||||
export const RespawnCycleStarted = 'respawn:cycleStarted' as const;
|
||||
/** Respawn cycle completed. */
|
||||
export const RespawnCycleCompleted = 'respawn:cycleCompleted' as const;
|
||||
/** Respawn blocked (e.g. by circuit breaker or active teammates). */
|
||||
export const RespawnBlocked = 'respawn:blocked' as const;
|
||||
/** Respawn step sent to session (update prompt, clear, kickstart). */
|
||||
export const RespawnStepSent = 'respawn:stepSent' as const;
|
||||
/** Respawn step completed. */
|
||||
export const RespawnStepCompleted = 'respawn:stepCompleted' as const;
|
||||
/** Idle/completion detection status updated. */
|
||||
export const RespawnDetectionUpdate = 'respawn:detectionUpdate' as const;
|
||||
/** Auto-accept sent for permission prompt. */
|
||||
export const RespawnAutoAcceptSent = 'respawn:autoAcceptSent' as const;
|
||||
/** AI idle check started. */
|
||||
export const RespawnAiCheckStarted = 'respawn:aiCheckStarted' as const;
|
||||
/** AI idle check completed with result. */
|
||||
export const RespawnAiCheckCompleted = 'respawn:aiCheckCompleted' as const;
|
||||
/** AI idle check failed. */
|
||||
export const RespawnAiCheckFailed = 'respawn:aiCheckFailed' as const;
|
||||
/** AI check cooldown state changed. */
|
||||
export const RespawnAiCheckCooldown = 'respawn:aiCheckCooldown' as const;
|
||||
/** Plan completion check started. */
|
||||
export const RespawnPlanCheckStarted = 'respawn:planCheckStarted' as const;
|
||||
/** Plan completion check completed with result. */
|
||||
export const RespawnPlanCheckCompleted = 'respawn:planCheckCompleted' as const;
|
||||
/** Plan completion check failed. */
|
||||
export const RespawnPlanCheckFailed = 'respawn:planCheckFailed' as const;
|
||||
/** Respawn timer started (idle, duration, etc.). */
|
||||
export const RespawnTimerStarted = 'respawn:timerStarted' as const;
|
||||
/** Respawn timer cancelled. */
|
||||
export const RespawnTimerCancelled = 'respawn:timerCancelled' as const;
|
||||
/** Respawn timer completed. */
|
||||
export const RespawnTimerCompleted = 'respawn:timerCompleted' as const;
|
||||
/** Respawn action logged (for monitor UI). */
|
||||
export const RespawnActionLog = 'respawn:actionLog' as const;
|
||||
/** Respawn debug log message. */
|
||||
export const RespawnLog = 'respawn:log' as const;
|
||||
/** Respawn error occurred. */
|
||||
export const RespawnError = 'respawn:error' as const;
|
||||
/** Respawn configuration updated. */
|
||||
export const RespawnConfigUpdated = 'respawn:configUpdated' as const;
|
||||
|
||||
// ─── Subagents ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** New subagent (background agent) discovered. */
|
||||
export const SubagentDiscovered = 'subagent:discovered' as const;
|
||||
/** Subagent state updated. */
|
||||
export const SubagentUpdated = 'subagent:updated' as const;
|
||||
/** Subagent tool call detected. */
|
||||
export const SubagentToolCall = 'subagent:tool_call' as const;
|
||||
/** Subagent tool result received. */
|
||||
export const SubagentToolResult = 'subagent:tool_result' as const;
|
||||
/** Subagent progress update. */
|
||||
export const SubagentProgress = 'subagent:progress' as const;
|
||||
/** Subagent message (assistant text). */
|
||||
export const SubagentMessage = 'subagent:message' as const;
|
||||
/** Subagent finished. */
|
||||
export const SubagentCompleted = 'subagent:completed' as const;
|
||||
|
||||
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Scheduled run created. */
|
||||
export const ScheduledCreated = 'scheduled:created' as const;
|
||||
/** Scheduled run state updated. */
|
||||
export const ScheduledUpdated = 'scheduled:updated' as const;
|
||||
/** Scheduled run completed. */
|
||||
export const ScheduledCompleted = 'scheduled:completed' as const;
|
||||
/** Scheduled run stopped. */
|
||||
export const ScheduledStopped = 'scheduled:stopped' as const;
|
||||
/** Scheduled run log entry added. */
|
||||
export const ScheduledLog = 'scheduled:log' as const;
|
||||
/** Scheduled run deleted. */
|
||||
export const ScheduledDeleted = 'scheduled:deleted' as const;
|
||||
|
||||
// ─── Teams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Agent team created. */
|
||||
export const TeamCreated = 'team:created' as const;
|
||||
/** Agent team config updated (e.g. new member joined). */
|
||||
export const TeamUpdated = 'team:updated' as const;
|
||||
/** Agent team removed. */
|
||||
export const TeamRemoved = 'team:removed' as const;
|
||||
/** Agent team task updated. */
|
||||
export const TeamTaskUpdated = 'team:taskUpdated' as const;
|
||||
|
||||
// ─── Transcript ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Transcript complete event detected. */
|
||||
export const TranscriptComplete = 'transcript:complete' as const;
|
||||
/** Plan mode detected in transcript. */
|
||||
export const TranscriptPlanMode = 'transcript:plan_mode' as const;
|
||||
/** Tool invocation started in transcript. */
|
||||
export const TranscriptToolStart = 'transcript:tool_start' as const;
|
||||
/** Tool invocation ended in transcript. */
|
||||
export const TranscriptToolEnd = 'transcript:tool_end' as const;
|
||||
|
||||
// ─── Plan Orchestration ──────────────────────────────────────────────────────
|
||||
|
||||
/** Plan generation started. */
|
||||
export const PlanStarted = 'plan:started' as const;
|
||||
/** Plan generation progress update. */
|
||||
export const PlanProgress = 'plan:progress' as const;
|
||||
/** Plan subagent event (research or planner agent). */
|
||||
export const PlanSubagent = 'plan:subagent' as const;
|
||||
/** Plan generation completed. */
|
||||
export const PlanCompleted = 'plan:completed' as const;
|
||||
/** Plan generation cancelled. */
|
||||
export const PlanCancelled = 'plan:cancelled' as const;
|
||||
|
||||
// ─── Tunnel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cloudflare tunnel started. */
|
||||
export const TunnelStarted = 'tunnel:started' as const;
|
||||
/** Cloudflare tunnel stopped. */
|
||||
export const TunnelStopped = 'tunnel:stopped' as const;
|
||||
/** Tunnel startup progress. */
|
||||
export const TunnelProgress = 'tunnel:progress' as const;
|
||||
/** Tunnel error. */
|
||||
export const TunnelError = 'tunnel:error' as const;
|
||||
/** QR code rotated (new token generated). */
|
||||
export const TunnelQrRotated = 'tunnel:qrRotated' as const;
|
||||
/** QR code force-regenerated. */
|
||||
export const TunnelQrRegenerated = 'tunnel:qrRegenerated' as const;
|
||||
/** QR auth token consumed by a client. */
|
||||
export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
|
||||
|
||||
// ─── Image ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New image file detected (e.g. screenshot upload). */
|
||||
export const ImageDetected = 'image:detected' as const;
|
||||
|
||||
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Claude Code hook: session idle, waiting for input. */
|
||||
export const HookIdlePrompt = 'hook:idle_prompt' as const;
|
||||
/** Claude Code hook: tool requesting permission. */
|
||||
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
|
||||
/** Claude Code hook: elicitation dialog (Claude asking a question). */
|
||||
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
|
||||
/** Claude Code hook: response complete. */
|
||||
export const HookStop = 'hook:stop' as const;
|
||||
/** Claude Code hook: teammate went idle. */
|
||||
export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
||||
/** Claude Code hook: teammate task completed. */
|
||||
export const HookTaskCompleted = 'hook:task_completed' as const;
|
||||
|
||||
// ─── Cases ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New case directory created. */
|
||||
export const CaseCreated = 'case:created' as const;
|
||||
/** Existing directory linked as a case. */
|
||||
export const CaseLinked = 'case:linked' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All SSE event types as a namespace object.
|
||||
* Convenient for destructured imports or passing as a group.
|
||||
*/
|
||||
export const SseEvent = {
|
||||
// Core
|
||||
Init,
|
||||
|
||||
// Session lifecycle
|
||||
SessionCreated,
|
||||
SessionUpdated,
|
||||
SessionDeleted,
|
||||
SessionTerminal,
|
||||
SessionNeedsRefresh,
|
||||
SessionClearTerminal,
|
||||
SessionCompletion,
|
||||
SessionError,
|
||||
SessionExit,
|
||||
SessionIdle,
|
||||
SessionWorking,
|
||||
SessionAutoClear,
|
||||
SessionAutoCompact,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
SessionRalphTodoUpdate,
|
||||
SessionRalphCompletionDetected,
|
||||
SessionRalphStatusUpdate,
|
||||
SessionCircuitBreakerUpdate,
|
||||
SessionExitGateMet,
|
||||
|
||||
// Session: Bash tools
|
||||
SessionBashToolStart,
|
||||
SessionBashToolEnd,
|
||||
SessionBashToolsUpdate,
|
||||
|
||||
// Session: Plan
|
||||
SessionPlanTaskUpdate,
|
||||
SessionPlanCheckpoint,
|
||||
SessionPlanRollback,
|
||||
SessionPlanTaskAdded,
|
||||
|
||||
// Tasks
|
||||
TaskCreated,
|
||||
TaskCompleted,
|
||||
TaskFailed,
|
||||
TaskUpdated,
|
||||
|
||||
// Mux
|
||||
MuxCreated,
|
||||
MuxKilled,
|
||||
MuxDied,
|
||||
MuxStatsUpdated,
|
||||
|
||||
// Respawn
|
||||
RespawnStarted,
|
||||
RespawnStopped,
|
||||
RespawnStateChanged,
|
||||
RespawnCycleStarted,
|
||||
RespawnCycleCompleted,
|
||||
RespawnBlocked,
|
||||
RespawnStepSent,
|
||||
RespawnStepCompleted,
|
||||
RespawnDetectionUpdate,
|
||||
RespawnAutoAcceptSent,
|
||||
RespawnAiCheckStarted,
|
||||
RespawnAiCheckCompleted,
|
||||
RespawnAiCheckFailed,
|
||||
RespawnAiCheckCooldown,
|
||||
RespawnPlanCheckStarted,
|
||||
RespawnPlanCheckCompleted,
|
||||
RespawnPlanCheckFailed,
|
||||
RespawnTimerStarted,
|
||||
RespawnTimerCancelled,
|
||||
RespawnTimerCompleted,
|
||||
RespawnActionLog,
|
||||
RespawnLog,
|
||||
RespawnError,
|
||||
RespawnConfigUpdated,
|
||||
|
||||
// Subagents
|
||||
SubagentDiscovered,
|
||||
SubagentUpdated,
|
||||
SubagentToolCall,
|
||||
SubagentToolResult,
|
||||
SubagentProgress,
|
||||
SubagentMessage,
|
||||
SubagentCompleted,
|
||||
|
||||
// Scheduled runs
|
||||
ScheduledCreated,
|
||||
ScheduledUpdated,
|
||||
ScheduledCompleted,
|
||||
ScheduledStopped,
|
||||
ScheduledLog,
|
||||
ScheduledDeleted,
|
||||
|
||||
// Teams
|
||||
TeamCreated,
|
||||
TeamUpdated,
|
||||
TeamRemoved,
|
||||
TeamTaskUpdated,
|
||||
|
||||
// Transcript
|
||||
TranscriptComplete,
|
||||
TranscriptPlanMode,
|
||||
TranscriptToolStart,
|
||||
TranscriptToolEnd,
|
||||
|
||||
// Plan orchestration
|
||||
PlanStarted,
|
||||
PlanProgress,
|
||||
PlanSubagent,
|
||||
PlanCompleted,
|
||||
PlanCancelled,
|
||||
|
||||
// Tunnel
|
||||
TunnelStarted,
|
||||
TunnelStopped,
|
||||
TunnelProgress,
|
||||
TunnelError,
|
||||
TunnelQrRotated,
|
||||
TunnelQrRegenerated,
|
||||
TunnelQrAuthUsed,
|
||||
|
||||
// Image
|
||||
ImageDetected,
|
||||
|
||||
// Hooks
|
||||
HookIdlePrompt,
|
||||
HookPermissionPrompt,
|
||||
HookElicitationDialog,
|
||||
HookStop,
|
||||
HookTeammateIdle,
|
||||
HookTaskCompleted,
|
||||
|
||||
// Cases
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user