Skip to content

Commit 43c2a9b

Browse files
committed
docs: Add init hooks documentation
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.
1 parent ce3b2de commit 43c2a9b

File tree

11 files changed

+140
-40
lines changed

11 files changed

+140
-40
lines changed

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- [Workspaces](./workspaces.md)
1212
- [Forking](./fork.md)
13+
- [Init Hooks](./init-hooks.md)
1314
- [Models](./models.md)
1415
- [Keyboard Shortcuts](./keybinds.md)
1516
- [Vim Mode](./vim-mode.md)

docs/init-hooks.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Init Hooks
2+
3+
Add a `.cmux/init` executable script to your project root to run commands when creating new workspaces.
4+
5+
## Example
6+
7+
```bash
8+
#!/bin/bash
9+
set -e
10+
11+
bun install
12+
bun run build
13+
```
14+
15+
Make it executable:
16+
17+
```bash
18+
chmod +x .cmux/init
19+
```
20+
21+
## Behavior
22+
23+
- **Runs once** per workspace on creation
24+
- **Streams output** to the workspace UI in real-time
25+
- **Non-blocking** - workspace is immediately usable, even while hook runs
26+
- **Exit codes preserved** - failures are logged but don't prevent workspace usage
27+
28+
The init script runs in the workspace directory with the workspace's environment.
29+
30+
## Use Cases
31+
32+
- Install dependencies (`npm install`, `bun install`, etc.)
33+
- Run build steps
34+
- Generate code or configs
35+
- Set up databases or services
36+
- Warm caches
37+
38+
## Output
39+
40+
Init output appears in a banner at the top of the workspace. Click to expand/collapse the log. The banner shows:
41+
42+
- Script path (`.cmux/init`)
43+
- Status (running, success, or exit code on failure)
44+
- Full stdout/stderr output
45+
46+
## Idempotency
47+
48+
The hook runs every time you create a workspace, even if you delete and recreate with the same name. Make your script idempotent if you're modifying shared state.

src/components/Messages/InitMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const InitMessage = React.memo<InitMessageProps>(({ message, className })
3535
</div>
3636
</div>
3737
{message.lines.length > 0 && (
38-
<pre className="m-0 max-h-[120px] overflow-auto whitespace-pre-wrap rounded border border-white/[0.08] bg-black/15 px-2 py-1.5">
38+
<pre className="m-0 max-h-[120px] overflow-auto rounded border border-white/[0.08] bg-black/15 px-2 py-1.5 whitespace-pre-wrap">
3939
{message.lines.join("\n")}
4040
</pre>
4141
)}

src/hooks/useWorkspaceManagement.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace";
33
import type { WorkspaceSelection } from "@/components/ProjectSidebar";
44
import type { ProjectConfig } from "@/config";
55
import { deleteWorkspaceStorage } from "@/constants/storage";
6+
import { getWorkspaceStoreForEagerSubscription } from "@/stores/WorkspaceStore";
67

78
interface UseWorkspaceManagementProps {
89
selectedWorkspace: WorkspaceSelection | null;
@@ -97,7 +98,22 @@ export function useWorkspaceManagement({
9798
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
9899
onProjectsUpdate(loadedProjects);
99100

100-
// Reload workspace metadata to get the new workspace ID
101+
// OPTIMIZATION: Subscribe immediately to workspace to receive init hook events in real-time
102+
// Without this, we'd wait for loadWorkspaceMetadata() + React effect, during which
103+
// early init hook events would be emitted but dropped (not subscribed yet).
104+
// Those events would then be replayed in a batch when subscription finally happens,
105+
// losing the real-time streaming UX.
106+
const workspaceStore = getWorkspaceStoreForEagerSubscription();
107+
workspaceStore.addWorkspace({
108+
id: result.metadata.id,
109+
name: result.metadata.name,
110+
projectName: result.metadata.projectName,
111+
projectPath: result.metadata.projectPath,
112+
namedWorkspacePath: result.metadata.namedWorkspacePath,
113+
createdAt: result.metadata.createdAt,
114+
});
115+
116+
// Reload workspace metadata to get the new workspace ID (for consistency with other state)
101117
await loadWorkspaceMetadata();
102118

103119
// Return the new workspace selection

src/services/bashExecutionService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class BashExecutionService {
136136
detached: config.detached ?? true,
137137
});
138138

139-
log.debug(`BashExecutionService: Spawned process with PID ${child.pid}`);
139+
log.debug(`BashExecutionService: Spawned process with PID ${child.pid ?? "unknown"}`);
140140

141141
// Line-by-line streaming with incremental buffers
142142
let outBuf = "";
@@ -168,7 +168,7 @@ export class BashExecutionService {
168168
});
169169

170170
child.on("close", (code: number | null) => {
171-
log.debug(`BashExecutionService: Process exited with code ${code}`);
171+
log.debug(`BashExecutionService: Process exited with code ${code ?? "unknown"}`);
172172
// Flush any remaining partial lines
173173
if (outBuf.trim().length > 0) {
174174
callbacks.onStdout(outBuf);

src/services/initStateManager.test.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@ describe("InitStateManager", () => {
3434
const events: Array<WorkspaceInitEvent & { workspaceId: string }> = [];
3535

3636
// Subscribe to events
37-
manager.on("init-start", (event) => events.push(event));
38-
manager.on("init-output", (event) => events.push(event));
39-
manager.on("init-end", (event) => events.push(event));
37+
manager.on("init-start", (event: WorkspaceInitEvent & { workspaceId: string }) =>
38+
events.push(event)
39+
);
40+
manager.on("init-output", (event: WorkspaceInitEvent & { workspaceId: string }) =>
41+
events.push(event)
42+
);
43+
manager.on("init-end", (event: WorkspaceInitEvent & { workspaceId: string }) =>
44+
events.push(event)
45+
);
4046

4147
// Start init
4248
manager.startInit(workspaceId, "/path/to/hook");
@@ -103,9 +109,15 @@ describe("InitStateManager", () => {
103109
const workspaceId = "test-workspace";
104110
const events: Array<WorkspaceInitEvent & { workspaceId: string }> = [];
105111

106-
manager.on("init-start", (event) => events.push(event));
107-
manager.on("init-output", (event) => events.push(event));
108-
manager.on("init-end", (event) => events.push(event));
112+
manager.on("init-start", (event: WorkspaceInitEvent & { workspaceId: string }) =>
113+
events.push(event)
114+
);
115+
manager.on("init-output", (event: WorkspaceInitEvent & { workspaceId: string }) =>
116+
events.push(event)
117+
);
118+
manager.on("init-end", (event: WorkspaceInitEvent & { workspaceId: string }) =>
119+
events.push(event)
120+
);
109121

110122
// Create state
111123
manager.startInit(workspaceId, "/path/to/hook");
@@ -138,9 +150,15 @@ describe("InitStateManager", () => {
138150
expect(manager.getInitState(workspaceId)).toBeUndefined();
139151

140152
// Subscribe to events
141-
manager.on("init-start", (event) => events.push(event));
142-
manager.on("init-output", (event) => events.push(event));
143-
manager.on("init-end", (event) => events.push(event));
153+
manager.on("init-start", (event: WorkspaceInitEvent & { workspaceId: string }) =>
154+
events.push(event)
155+
);
156+
manager.on("init-output", (event: WorkspaceInitEvent & { workspaceId: string }) =>
157+
events.push(event)
158+
);
159+
manager.on("init-end", (event: WorkspaceInitEvent & { workspaceId: string }) =>
160+
events.push(event)
161+
);
144162

145163
// Replay from disk
146164
await manager.replayInit(workspaceId);
@@ -160,9 +178,15 @@ describe("InitStateManager", () => {
160178
const workspaceId = "nonexistent-workspace";
161179
const events: Array<WorkspaceInitEvent & { workspaceId: string }> = [];
162180

163-
manager.on("init-start", (event) => events.push(event));
164-
manager.on("init-output", (event) => events.push(event));
165-
manager.on("init-end", (event) => events.push(event));
181+
manager.on("init-start", (event: WorkspaceInitEvent & { workspaceId: string }) =>
182+
events.push(event)
183+
);
184+
manager.on("init-output", (event: WorkspaceInitEvent & { workspaceId: string }) =>
185+
events.push(event)
186+
);
187+
manager.on("init-end", (event: WorkspaceInitEvent & { workspaceId: string }) =>
188+
events.push(event)
189+
);
166190

167191
await manager.replayInit(workspaceId);
168192

src/services/initStateManager.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,9 @@ export interface InitStatus {
1919

2020
/**
2121
* In-memory state for active init hooks.
22-
* Extends InitStatus with event emission tracking.
22+
* Currently identical to InitStatus, but kept separate for future extension.
2323
*/
24-
interface InitHookState extends InitStatus {
25-
// No additional fields needed for now, but keeps type separate for future extension
26-
}
24+
type InitHookState = InitStatus;
2725

2826
/**
2927
* InitStateManager - Manages init hook lifecycle with persistence and replay.
@@ -66,8 +64,8 @@ export class InitStateManager extends EventEmitter {
6664
*/
6765
private serializeInitEvents(
6866
state: InitHookState & { workspaceId?: string }
69-
): (WorkspaceInitEvent & { workspaceId: string })[] {
70-
const events: (WorkspaceInitEvent & { workspaceId: string })[] = [];
67+
): Array<WorkspaceInitEvent & { workspaceId: string }> {
68+
const events: Array<WorkspaceInitEvent & { workspaceId: string }> = [];
7169
const workspaceId = state.workspaceId ?? "unknown";
7270

7371
// Emit init-start

src/stores/WorkspaceStore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,15 @@ function getStoreInstance(): WorkspaceStore {
10241024
return storeInstance;
10251025
}
10261026

1027+
/**
1028+
* Get the WorkspaceStore instance for eager workspace subscription.
1029+
* Used by useWorkspaceManagement to subscribe to new workspaces immediately
1030+
* after creation, before React effects run.
1031+
*/
1032+
export function getWorkspaceStoreForEagerSubscription(): WorkspaceStore {
1033+
return getStoreInstance();
1034+
}
1035+
10271036
/**
10281037
* Hook to get state for a specific workspace.
10291038
* Only re-renders when THIS workspace's state changes.

src/utils/eventStore.test.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2-
import * as fs from "fs";
2+
import * as fs from "fs/promises";
33
import * as path from "path";
44
import { EventStore } from "./eventStore";
55
import type { Config } from "@/config";
@@ -42,10 +42,12 @@ describe("EventStore", () => {
4242
emittedEvents.push(event);
4343
};
4444

45-
beforeEach(() => {
45+
beforeEach(async () => {
4646
// Create test session directory
47-
if (!fs.existsSync(testSessionDir)) {
48-
fs.mkdirSync(testSessionDir, { recursive: true });
47+
try {
48+
await fs.access(testSessionDir);
49+
} catch {
50+
await fs.mkdir(testSessionDir, { recursive: true });
4951
}
5052

5153
mockConfig = {
@@ -59,10 +61,13 @@ describe("EventStore", () => {
5961
store = new EventStore(mockConfig, testFilename, serializeState, emitEvent, "TestStore");
6062
});
6163

62-
afterEach(() => {
64+
afterEach(async () => {
6365
// Clean up test files
64-
if (fs.existsSync(testSessionDir)) {
65-
fs.rmSync(testSessionDir, { recursive: true, force: true });
66+
try {
67+
await fs.access(testSessionDir);
68+
await fs.rm(testSessionDir, { recursive: true, force: true });
69+
} catch {
70+
// Directory doesn't exist, nothing to clean up
6671
}
6772
});
6873

@@ -119,11 +124,15 @@ describe("EventStore", () => {
119124
// Verify file exists
120125
const workspaceDir = path.join(testSessionDir, testWorkspaceId);
121126
const filePath = path.join(workspaceDir, testFilename);
122-
expect(fs.existsSync(filePath)).toBe(true);
127+
try {
128+
await fs.access(filePath);
129+
} catch {
130+
throw new Error(`File ${filePath} does not exist`);
131+
}
123132

124133
// Verify content
125-
const content = fs.readFileSync(filePath, "utf-8");
126-
const parsed = JSON.parse(content);
134+
const content = await fs.readFile(filePath, "utf-8");
135+
const parsed = JSON.parse(content) as TestState;
127136
expect(parsed).toEqual(state);
128137
});
129138

@@ -240,4 +249,3 @@ describe("EventStore", () => {
240249
});
241250
});
242251
});
243-

src/utils/eventStore.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { EventEmitter } from "events";
21
import { SessionFileManager } from "@/utils/sessionFile";
32
import type { Config } from "@/config";
43
import { log } from "@/services/log";
@@ -147,8 +146,6 @@ export class EventStore<TState, TEvent> {
147146
state = diskState;
148147
}
149148

150-
log.debug(`[${this.storeName}] Replaying events for ${workspaceId}`);
151-
152149
// Augment state with context for serialization
153150
const augmentedState = { ...state, ...context };
154151

@@ -196,4 +193,3 @@ export class EventStore<TState, TEvent> {
196193
*
197194
* See InitStateManager refactor (this PR) for reference implementation.
198195
*/
199-

0 commit comments

Comments
 (0)