-
Notifications
You must be signed in to change notification settings - Fork 5
🤖 Stream .cmux/init hook on workspace creation #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
d5750bc to
d0170f2
Compare
ba5cc88 to
97cde93
Compare
Complete end-to-end infrastructure for streaming .cmux/init hook output: - Backend detects and runs optional project-level .cmux/init on workspace creation - Stream stdout/stderr lines to renderer via WORKSPACE_STREAM_META IPC channel - Buffer output for late subscribers; replay on subscribe - UI displays live output with auto-hide on success, persistent banner on failure - Workspace remains usable regardless of hook exit code Backend (ipcMain.ts): - Add WorkspaceMetaEvent type imports (fix inline import() eslint errors) - Implement runWorkspaceInitHook with line-buffered streaming - Add metaEventBuffer for replay to late subscribers - Remove duplicate workspace:meta:subscribe handlers Frontend (AIView.tsx): - Add typed WorkspaceMetaEvent handler (no any types) - Prefer line when present in error events, else error field - Auto-hide success banner (800ms), persist failure banner Types: - Add WorkspaceMetaEvent union (start/output/error/end) - Strong typing throughout preload and IPC layer Tests: - Add integration test suite (workspaceInitHook.test.ts) - Verify start/output/error/end event sequence - Verify workspace remains usable on hook failure - Verify no events when hook absent Generated with cmux fix: Add onMeta implementation to browser API refactor: Unify init hooks with chat stream Simplifies init hook architecture by reusing existing chat stream infrastructure instead of creating parallel IPC system. Changes: - Add WorkspaceInitEvent union to WorkspaceChatMessage (3 event types) - Backend emits init events via AgentSession.emitChatEvent() - Frontend handles init events from chat subscription - Tests filter init events from chat channel - Remove metaEventBuffer, onMeta(), and WORKSPACE_STREAM_META Benefits: - ~80 net LoC reduction (removed ~150, added ~70) - 1 subscription per workspace instead of 2 - Automatic replay via existing history mechanism - Cleaner types (no redundant workspaceId field) - Single buffer for all workspace events Init events are ephemeral (not persisted) and flow through the same stream as caught-up, stream-error, and delete messages. 🤖 refactor: Integrate init hooks into DisplayedMessage pattern and centralize bash execution - Added workspace-init DisplayedMessage type with status tracking - Extended StreamingMessageAggregator to convert init events to DisplayedMessage - Created InitMessage component to render init banners in message stream - Removed local init state management from AIView (eliminated parallel infrastructure) - Removed legacy WorkspaceMetaEvent type (no longer used) - Created BashExecutionService to centralize all bash execution - Provides single abstraction point for future host migration (containers, remote, etc.) - Eliminates duplicate environment setup across init hooks and bash tool - executeStreaming() mode for line-by-line output (init hooks) - Updated IpcMain to use BashExecutionService for init hook execution Benefits: - Init events flow through same path as other workspace events - Centralized state management (no local component state) - Single source of truth for bash environment setup - Easier to abstract workspace hosts in future Tests: - Added unit tests for aggregator init handling (2 tests) - All integration tests passing (3/3 init hook tests) - Typecheck passing for both renderer and main processes 🤖 feat: Add comprehensive logging to init hook execution - Added log.debug() for init hook detection and script execution - Added log.info() for init hook start and completion with exit codes - Added log.error() for init hook failures - Added logging to BashExecutionService for streaming command execution - Added process error logging for bash execution failures This improves debuggability when init hooks don't work as expected. 🤖 fix: Wire up init events to frontend WorkspaceStore Init events were being buffered until "caught-up" but init hooks run during workspace creation, before the workspace has any history or caught-up status. Changes: - Added isInitStart, isInitOutput, isInitEnd imports to WorkspaceStore - Updated isStreamEvent() to include init events (process immediately) - Added explicit init event handling in processStreamEvent() - Init events now bypass caught-up check and process immediately This ensures the init banner appears in the UI when workspaces are created. Revert "🤖 fix: Wire up init events to frontend WorkspaceStore" This reverts commit ba5cc88. 🤖 refactor: extract EventStore abstraction from InitStateManager Abstracts the shared event storage and replay pattern into a reusable EventStore utility, eliminating duplication within InitStateManager and establishing a pattern for future StreamManager refactoring. - **EventStore<TState, TEvent>** (199 LoC): Generic utility for managing workspace state with in-memory Map, disk persistence, and replay logic - **InitStateManager refactored**: Simplified from 252→246 LoC using EventStore, eliminating duplicated event emission and manual replay loops - **serializeInitEvents()**: Single method generates all replay events from state - Eliminates internal duplication (repeated event emission, manual replay loops) - Composition-based design (inject emit function, no EventEmitter inheritance) - Type-safe with generic TState/TEvent parameters - Context injection for replay (e.g., workspaceId augmentation) - Documented pattern for future StreamManager adoption - EventStore: 16 unit tests (state management, persistence, replay, integration) - InitStateManager: All 12 tests pass unchanged - Integration: All 4 init hook tests pass - Total: 748/748 tests passing (no regressions) _Generated with `cmux`_ 🤖 fix: wire up init events to frontend WorkspaceStore Init events were being buffered until "caught-up" but init hooks run during workspace creation, before the workspace has any history or caught-up status. Changes: - Added isInitStart, isInitOutput, isInitEnd imports to WorkspaceStore - Updated isStreamEvent() to include init events (process immediately) - Added explicit init event handling in processStreamEvent() - Init events now bypass caught-up check and process immediately This ensures the init banner appears in the UI when workspaces are created. Backend integration tests verify that init events are correctly emitted through IPC to the frontend. _Generated with `cmux`_ 🤖 refactor: document caught-up optimization and simplify init event flow Added comprehensive documentation explaining why WorkspaceStore buffers events until "caught-up" status - to avoid O(N) re-renders when loading workspaces with long histories. Changes: - Added detailed comment block explaining caught-up buffering optimization - Clarified that init events are buffered like other stream events - Updated comment in processStreamEvent() to reflect buffered init events - Init events now follow the same flow as other stream events (buffered until caught-up) This simplifies the code by removing special-case handling - init events are just another type of stream event that benefits from the buffering optimization. _Generated with `cmux`_ 🤖 docs: clarify init event buffering comment The previous comment at line 959 was misleading - it said 'processed after caught-up' but appeared to be before any caught-up check. Clarified that: - Init events ARE buffered in handleChatMessage() until caught-up - The code at line 959 processes them AFTER buffering/replay - By the time we reach this code, buffering decision already happened This makes the control flow clearer: handleChatMessage() does buffering, processStreamEvent() does processing. _Generated with `cmux`_ 🤖 feat: add init hook tip to workspace EmptyState Updated the "No Messages Yet" empty state in workspace view to educate users about the .cmux/init hook feature. Changes: - Added tip about .cmux/init hook below the main empty state message - Styled code tag with monospace font, subtle background, and color - Tip appears in smaller, muted text with lightbulb emoji - Briefly explains use case (install dependencies, build) This provides discoverable education for new users who create workspaces and helps them understand how to set up automated initialization. _Generated with `cmux`_ 🤖 feat: display init hook script path in banner for debugging The init hook banner now shows the full path to the script being executed, making it easier for users to debug initialization issues. Changes: - Added `hookPath` field to workspace-init DisplayedMessage type - Updated StreamingMessageAggregator to capture and track hookPath from init-start events - Enhanced InitMessage component to display hookPath below status message - Styled hookPath with muted monospace font for clarity The hookPath appears in smaller, muted text below the main status message, helping users quickly identify which script is running and where to find it. Output streaming already worked - init-output events were already being accumulated and displayed in the InitHookLog component. _Generated with `cmux`_ fix: Process init events immediately for real-time display Init events were being buffered until 'caught-up', preventing real-time display during workspace creation. Since init hooks run BEFORE any chat history exists, they should be processed immediately, not buffered. Changes: - Init events now bypass the buffering logic in handleChatMessage() - Updated comments to reflect that init events are not buffered - All 764 unit tests pass - All 4 init hook integration tests pass refactor: Convert styling to Tailwind and fix caught-up timing Three improvements addressing code review feedback: 1. **AIView.tsx empty state** - Convert inline styles to Tailwind classes - Changed inline style object to `mt-5 text-xs text-[#888]` 2. **InitMessage.tsx** - Migrate from styled-components to Tailwind - Removed @emotion/styled dependency usage - Converted all styled components to Tailwind utility classes - Maintains identical visual appearance 3. **Fix caught-up timing for real-time init display** - Send caught-up IMMEDIATELY after chat history loads - Replay init events AFTER caught-up (not before) - Init events are workspace lifecycle metadata, not chat history - Eliminates O(N) re-renders during init hook execution **Why this works:** - Chat history = buffered until caught-up (prevents render thrashing) - Init events = processed in real-time after caught-up (no buffering) - New workspaces: caught-up sent instantly → init streams in real-time ✅ - Page reload: caught-up sent after history → init replayed from disk ✅ All 769 unit tests + 4 init hook integration tests pass.
564af80 to
676e474
Compare
Added user-facing documentation for .cmux/init hooks under Workspaces section. Covers: - Basic example with setup instructions - Behavior (runs once, streams output, non-blocking, exit codes) - Common use cases (deps, builds, codegen, services) - Output display (banner with status and logs) - Idempotency considerations Follows docs/STYLE.md guidelines: - Assumes technical competence - Focuses on non-obvious behavior (non-blocking, idempotency) - Concise and practical examples - No obvious details fix: Subscribe to workspace immediately after creation for real-time init events When a workspace is created, the init hook starts running immediately in the background. However, the frontend previously waited for React effects to process the workspace metadata update before subscribing to events. This created a race condition where early init hook output lines were emitted before the frontend subscribed, causing them to be dropped at the WebSocket layer (only subscribed clients receive messages). Although these events would be replayed when subscription finally happened, this broke the real-time streaming UX - users saw all output appear at once in a batch instead of streaming line-by-line. Fix by calling workspaceStore.addWorkspace() immediately after receiving the workspace creation response, before React effects run. This ensures the frontend is subscribed before (or very quickly after) the init hook starts emitting events, preserving the real-time streaming experience. Also export getWorkspaceStoreForEagerSubscription() to allow non-React code to access the singleton store instance for imperative operations.
676e474 to
43c2a9b
Compare
- Move replayInit() before caught-up event in agentSession.ts
- Init events are historical data and should be replayed before caught-up signal
- Ensures frontend buffers init events correctly with other historical data
- Remove eager subscription workaround
- Removed getWorkspaceStoreForEagerSubscription() from WorkspaceStore
- Removed eager subscription logic from useWorkspaceManagement
- Workaround was unnecessary with correct replay order
- Improve TimedLine data structure
- Replace lines: string[] with lines: TimedLine[] in InitStatus
- TimedLine = { line, isError, timestamp }
- Store isError as boolean instead of "ERROR:" prefix hack
- Preserve timestamps for accurate event replay
- Fix test timing measurement
- Capture timestamps when events are sent, not when observed
- Update TestEnvironment to include timestamp in sentEvents
- Fix workspaceInitHook test to use real event timestamps
- Adjust first-event delay expectation (500ms → 1000ms for bash startup)
- Update all tests for new TimedLine structure
- Fix initStateManager unit tests
- Fix persistence integration test
- All 769 unit tests + 5 integration tests passing
Events now stream with natural timing (~120-140ms apart) matching the
100ms sleep between lines in test hooks. The "batching" issue was a
test measurement bug - events were streaming correctly all along.
Root cause: React.memo change detection failing due to array reference reuse. When init-output events arrived, StreamingMessageAggregator would: 1. Push new line to this.initState.lines array (mutating in place) 2. Invalidate cache and rebuild DisplayedMessage 3. BUT: new DisplayedMessage referenced the SAME lines array React.memo saw identical array reference and skipped re-render, so UI only updated when init-end arrived (status change forced re-render). Fix: Create shallow copy of lines array when building DisplayedMessage. Now each init-output creates new array reference, triggering re-render. Also removed init events from isStreamEvent() - they're workspace lifecycle events that should process immediately, not buffer until caught-up like regular stream events. All 5 integration tests passing with ~120ms streaming timing.
Add 4 unit tests for StreamingMessageAggregator to ensure proper reference stability for React.memo change detection: 1. **Array reference changes on state change**: Verifies getDisplayedMessages() returns new array when init state changes (cache invalidation works) 2. **Lines array gets shallow copied**: Critical test - ensures lines array is a new reference when init-output arrives, not the same mutated array 3. **New init message object per change**: Verifies each state change creates new DisplayedMessage object with new lines array reference 4. **Cache returns same reference when unchanged**: Verifies optimization - repeated calls without state changes return cached reference These tests would have caught the bug where lines array was directly referenced instead of shallow copied, breaking React.memo detection.
Add console.debug logs to StreamingMessageAggregator for init events: - init-start: Log hookPath and timestamp - init-output: Log line content, isError flag, and running total - init-end: Log exit code, status, and total lines - Auto-dismiss: Log when successful init is auto-dismissed - Serialization: Log when init state is converted to DisplayedMessage Also add warnings when init-output/init-end arrive without active init state (helps catch event ordering bugs). These logs help debug: - Init event timing and order - State transitions (running → success/error) - React render triggers (cache invalidation) - Reference stability issues (can verify new array created)
0a30a16 to
45584ad
Compare
Add console.debug logs for init-start, init-output, and init-end events: - init-start: Log hookPath and timestamp when init begins - init-output: Log line content, isError flag, and running line count - init-end: Log exit code, final status, and total line count - Auto-dismiss: Log when successful init is auto-dismissed after 800ms Also add warnings when init-output/init-end arrive without active init state (helps catch event ordering bugs). These logs complement the existing serialization log, providing full visibility into init event lifecycle from arrival through rendering.
fad650a to
011b5ec
Compare
Root cause: workspace.create returned before init hook started, causing race between event emission and frontend subscription. Early events were lost (emitted before IPC listener registered). Solution: Refactor runWorkspaceInitHook → startWorkspaceInitHook (async) and await it in workspace.create. Now: 1. Create workspace metadata 2. Call startInit() to create in-memory state 3. Return from workspace.create (frontend can now subscribe) 4. Init hook process runs async (emits events to subscribed frontend) This guarantees: - In-memory state exists before workspace.create returns - replayInit() always finds state (no empty replay) - All init events have active subscription (none lost) - Fast: only waits for hook to START (~instant), not complete Live streaming and replay now produce identical UI states. Net change: ~10 LoC (refactor fire-and-forget to async/await)
011b5ec to
faa8fca
Compare
- Remove console.debug/log statements from StreamingMessageAggregator - Init event logging (start, output, end, auto-dismiss) - Tool call logging (start, delta) - Remove auto-dismiss timeout for successful init messages - Init messages now persist in UI regardless of status - Remove defensive null checks in init event handling - Race condition fixed in previous commit guarantees state exists - Simplify comment in WorkspaceStore for init event processing - Remove debug console.log from integration test timing measurements - Add .cmux/init hook that runs bun install for new workspaces Generated with `cmux`
- Remove unnecessary async from IIFE in ipcMain.ts (no await inside) - Add type assertions for expect.any(Number) in tests to fix unsafe assignment warnings Generated with `cmux`
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Fixes issue where init hooks fail silently when project paths contain spaces (e.g., '~/Code/My Project/.cmux/init'). The bash service runs commands with 'bash -c', which requires proper quoting. Generated with `cmux`
Init events already bypass buffering because: - They have a 'type' field, so don't match the historical message check - The regular message path handles them correctly - This removes 5 LoC and simplifies the logic Generated with `cmux`
067128d to
6ed9523
Compare
After removing the special case, init events now flow through the regular message path. Updated comment to clarify what's excluded from stream event buffering. Generated with `cmux`
Init events ARE persisted to disk (init-status.json), just not to chat history (chat.jsonl). Updated comment to reflect this. Generated with `cmux`
e7062c2 to
999d3c8
Compare
The cleanup removed necessary defensive checks, causing crashes when init-output or init-end arrive without init-start (can happen during replay or out-of-order events). Restored graceful handling. Added TDD test to prevent regression. Generated with `cmux`
999d3c8 to
4d9e8d1
Compare
After removing special case, init events fell through the buffering check but never reached processing logic. Changed to process all non-bufferable events immediately (init events, live stream events, etc.) This was the actual bug preventing init display in UI. Generated with `cmux`
afe83cd to
20bdcc6
Compare
Init events can arrive before or after caught-up: - Before: During replay from init-status.json (historical) - After: During live workspace creation (real-time) Like stream events, init events are now explicitly buffered during replay to avoid O(N) re-renders and ensure proper ordering. Added detailed comments explaining this behavior. Generated with `cmux`
20bdcc6 to
1e65716
Compare
Init events are buffered like stream events but were missing handlers in processStreamEvent(), causing them to be silently dropped. Generated with `cmux`
531b22e to
56e9dac
Compare
Replaced separate isStreamEvent() check and processStreamEvent() dispatch with a single event handler map. This makes it impossible to add a buffered event type without a handler: - Single source of truth: bufferedEventHandlers map defines both which events to buffer (keys) and how to handle them (values) - No synchronization bugs: Can't add to one without the other - Simpler code: ~100 LoC of if-chains replaced with map dispatch - Self-documenting: Map keys show all buffered event types at a glance Net: -30 LoC, same behavior, zero risk of silent drops Generated with `cmux`
56e9dac to
4fbc79b
Compare
Fixes two issues found after PR #228: 1. **Init-output undefined line crash**: Added null check for data.line before calling trimEnd(). Prevents crash when init-output events arrive with missing line data during replay or out-of-order scenarios. 2. **Message processing condition tightened**: Added 'role' field check to ensure only CmuxMessages are processed in the caught-up branch. This makes the condition symmetric with the buffering branch and ensures we don't accidentally process malformed messages. Both changes follow the defensive programming pattern established in PR #228 - gracefully handle edge cases during replay rather than crash. _Generated with `cmux`_
## Problem After #228 merged, encountered two issues: 1. **Init-output crash**: `StreamingMessageAggregator.ts:516` throws `Cannot read properties of undefined (reading 'trimEnd')` when processing init-output events with missing `line` field 2. **Inconsistent message appearance**: User messages sometimes don't appear immediately in chat view ## Solution ### 1. Init-output defensive check Added null check for `data.line` before calling `trimEnd()`: ```typescript if (!data.line) return; // Defensive: skip events with missing line data ``` Follows the defensive pattern from #228 - gracefully handle edge cases during replay. ### 2. Message processing condition tightened Added `'role' in data` check to the caught-up branch: ```typescript } else if (isCaughtUp && "role" in data) { ``` Makes the condition symmetric with the buffering branch and ensures only valid CmuxMessages are processed. ## Testing - ✅ `make typecheck` passes - ✅ All 763 unit tests pass _Generated with `cmux`_
Connects the init hooks system (PR #228) with the Runtime abstraction so workspace creation progress and init hook output stream to the frontend. **Init Hook Utilities (src/runtime/initHook.ts):** - checkInitHookExists(): Check if .cmux/init is executable - getInitHookPath(): Get init hook path for project - LineBuffer class: Line-buffered streaming (handles incomplete lines) - createLineBufferedLoggers(): Creates stdout/stderr line buffers **Runtime Integration:** - InitLogger interface: logStep(), logStdout(), logStderr(), logComplete() - WorkspaceCreationParams extended with initLogger - LocalRuntime: Runs init hook locally via bash, streams output - SSHRuntime: Runs init hook on remote host, streams via Web Streams **IPC Bridge:** - IpcMain creates InitLogger that bridges to InitStateManager - Runtime owns workspace creation entirely (no IPC branching) - Creation steps logged: "Creating worktree...", "Running init hook..." - Real-time streaming to frontend via existing init channels **Testing:** - 7 unit tests for LineBuffer and createLineBufferedLoggers - Integration tests updated with mockInitLogger - All 770 tests passing Generated with `cmux`
Connects the init hooks system (PR #228) with the Runtime abstraction so workspace creation progress and init hook output stream to the frontend. **Init Hook Utilities (src/runtime/initHook.ts):** - checkInitHookExists(): Check if .cmux/init is executable - getInitHookPath(): Get init hook path for project - LineBuffer class: Line-buffered streaming (handles incomplete lines) - createLineBufferedLoggers(): Creates stdout/stderr line buffers **Runtime Integration:** - InitLogger interface: logStep(), logStdout(), logStderr(), logComplete() - WorkspaceCreationParams extended with initLogger - LocalRuntime: Runs init hook locally via bash, streams output - SSHRuntime: Runs init hook on remote host, streams via Web Streams **IPC Bridge:** - IpcMain creates InitLogger that bridges to InitStateManager - Runtime owns workspace creation entirely (no IPC branching) - Creation steps logged: "Creating worktree...", "Running init hook..." - Real-time streaming to frontend via existing init channels **Testing:** - 7 unit tests for LineBuffer and createLineBufferedLoggers - Integration tests updated with mockInitLogger - All 770 tests passing Generated with `cmux`
Connects the init hooks system (PR #228) with the Runtime abstraction so workspace creation progress and init hook output stream to the frontend. **Init Hook Utilities (src/runtime/initHook.ts):** - checkInitHookExists(): Check if .cmux/init is executable - getInitHookPath(): Get init hook path for project - LineBuffer class: Line-buffered streaming (handles incomplete lines) - createLineBufferedLoggers(): Creates stdout/stderr line buffers **Runtime Integration:** - InitLogger interface: logStep(), logStdout(), logStderr(), logComplete() - WorkspaceCreationParams extended with initLogger - LocalRuntime: Runs init hook locally via bash, streams output - SSHRuntime: Runs init hook on remote host, streams via Web Streams **IPC Bridge:** - IpcMain creates InitLogger that bridges to InitStateManager - Runtime owns workspace creation entirely (no IPC branching) - Creation steps logged: "Creating worktree...", "Running init hook..." - Real-time streaming to frontend via existing init channels **Testing:** - 7 unit tests for LineBuffer and createLineBufferedLoggers - Integration tests updated with mockInitLogger - All 770 tests passing Generated with `cmux`
Connects the init hooks system (PR #228) with the Runtime abstraction so workspace creation progress and init hook output stream to the frontend. **Init Hook Utilities (src/runtime/initHook.ts):** - checkInitHookExists(): Check if .cmux/init is executable - getInitHookPath(): Get init hook path for project - LineBuffer class: Line-buffered streaming (handles incomplete lines) - createLineBufferedLoggers(): Creates stdout/stderr line buffers **Runtime Integration:** - InitLogger interface: logStep(), logStdout(), logStderr(), logComplete() - WorkspaceCreationParams extended with initLogger - LocalRuntime: Runs init hook locally via bash, streams output - SSHRuntime: Runs init hook on remote host, streams via Web Streams **IPC Bridge:** - IpcMain creates InitLogger that bridges to InitStateManager - Runtime owns workspace creation entirely (no IPC branching) - Creation steps logged: "Creating worktree...", "Running init hook..." - Real-time streaming to frontend via existing init channels **Testing:** - 7 unit tests for LineBuffer and createLineBufferedLoggers - Integration tests updated with mockInitLogger - All 770 tests passing Generated with `cmux`
Summary
Streams
.cmux/inithook output live to UI during workspace creation. The init hook is now fully functional with proper event streaming, race-condition-free replay, and clean UX.Key Features
Implementation
Backend Changes
src/services/ipcMain.ts: AwaitstartWorkspaceInitHook()to ensure state exists before workspace creation returnssrc/services/initStateManager.ts: Manages init state lifecycle, disk persistence, event emissionsrc/services/AgentSession.ts: Forwards init events to IPC, replays init state before caught-up signalFrontend Changes
src/stores/WorkspaceStore.ts: Processes init events immediately (not buffered), handles subscription/replaysrc/utils/messages/StreamingMessageAggregator.ts: Converts init state to DisplayedMessage with shallow array copies for Reactsrc/components/Messages/InitMessage.tsx: Renders init output with status indicatorsCleanup
.cmux/inithook that runsbun installTesting
Example
When creating a workspace, if
.cmux/initexists:Output streams live to UI:
Generated with
cmux