Skip to content

Commit c596556

Browse files
authored
🤖 Stream .cmux/init hook on workspace creation (#228)
## Summary Streams `.cmux/init` hook 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 - **Live streaming**: Init hook output appears in real-time as lines are emitted - **Race-free**: Backend ensures state exists before IPC returns, frontend subscribes and replays without buffering issues - **Persistent**: Init messages remain visible in UI (success and error states) - **React-optimized**: Proper array references trigger React.memo change detection - **Clean UX**: Init messages display at top of chat with status indicators ## Implementation ### Backend Changes - `src/services/ipcMain.ts`: Await `startWorkspaceInitHook()` to ensure state exists before workspace creation returns - `src/services/initStateManager.ts`: Manages init state lifecycle, disk persistence, event emission - `src/services/AgentSession.ts`: Forwards init events to IPC, replays init state before caught-up signal ### Frontend Changes - `src/stores/WorkspaceStore.ts`: Processes init events immediately (not buffered), handles subscription/replay - `src/utils/messages/StreamingMessageAggregator.ts`: Converts init state to DisplayedMessage with shallow array copies for React - `src/components/Messages/InitMessage.tsx`: Renders init output with status indicators ### Cleanup - Removed debug logging from init event handlers - Removed auto-dismiss timeout (init messages persist) - Simplified defensive checks (race condition fixed) - Added `.cmux/init` hook that runs `bun install` ## Testing - **773 unit tests passing** - **5 integration tests passing** (init hook streaming with real timing verification) - Tests verify events arrive ~100ms apart (not batched), matching script sleep intervals - Replay behavior matches live streaming (invariant preserved) ## Example When creating a workspace, if `.cmux/init` exists: ```bash #!/usr/bin/env bash echo "Installing dependencies..." bun install echo "Done!" ``` Output streams live to UI: ``` 🔄 Running init hook (.cmux/init) Installing dependencies... [bun install output...] Done! ✅ Init completed successfully (exit code 0) ``` _Generated with `cmux`_
1 parent 014e0cc commit c596556

26 files changed

+2363
-833
lines changed

.cmux/init

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
echo "Installing dependencies with bun..."
5+
bun install
6+
echo "Dependencies installed successfully!"
7+

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/AIView.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
230230

231231
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
232232
const editCutoffHistoryId = mergedMessages.find(
233-
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
234-
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
233+
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
234+
msg.type !== "history-hidden" &&
235+
msg.type !== "workspace-init" &&
236+
msg.historyId === editingMessage.id
235237
)?.historyId;
236238

237239
if (!editCutoffHistoryId) {
@@ -277,8 +279,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
277279
// When editing, find the cutoff point
278280
const editCutoffHistoryId = editingMessage
279281
? mergedMessages.find(
280-
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
281-
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
282+
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
283+
msg.type !== "history-hidden" &&
284+
msg.type !== "workspace-init" &&
285+
msg.historyId === editingMessage.id
282286
)?.historyId
283287
: undefined;
284288

@@ -381,19 +385,33 @@ const AIViewInner: React.FC<AIViewProps> = ({
381385
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
382386
<h3>No Messages Yet</h3>
383387
<p>Send a message below to begin</p>
388+
<p className="mt-5 text-xs text-[#888]">
389+
💡 Tip: Add a{" "}
390+
<code className="rounded-[3px] bg-[#2d2d30] px-1.5 py-0.5 font-mono text-[11px] text-[#d7ba7d]">
391+
.cmux/init
392+
</code>{" "}
393+
hook to your project to run setup commands
394+
<br />
395+
(e.g., install dependencies, build) when creating new workspaces
396+
</p>
384397
</div>
385398
) : (
386399
<>
387400
{mergedMessages.map((msg) => {
388401
const isAtCutoff =
389402
editCutoffHistoryId !== undefined &&
390403
msg.type !== "history-hidden" &&
404+
msg.type !== "workspace-init" &&
391405
msg.historyId === editCutoffHistoryId;
392406

393407
return (
394408
<React.Fragment key={msg.id}>
395409
<div
396-
data-message-id={msg.type !== "history-hidden" ? msg.historyId : undefined}
410+
data-message-id={
411+
msg.type !== "history-hidden" && msg.type !== "workspace-init"
412+
? msg.historyId
413+
: undefined
414+
}
397415
>
398416
<MessageRenderer
399417
message={msg}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from "react";
2+
import { cn } from "@/lib/utils";
3+
import type { DisplayedMessage } from "@/types/message";
4+
5+
interface InitMessageProps {
6+
message: Extract<DisplayedMessage, { type: "workspace-init" }>;
7+
className?: string;
8+
}
9+
10+
export const InitMessage = React.memo<InitMessageProps>(({ message, className }) => {
11+
const isError = message.status === "error";
12+
13+
return (
14+
<div
15+
className={cn(
16+
"flex flex-col gap-1.5 border-b p-3 font-mono text-xs text-[#ddd]",
17+
isError ? "bg-[#3a1e1e] border-[#653737]" : "bg-[#1e2a3a] border-[#2f3f52]",
18+
className
19+
)}
20+
>
21+
<div className="flex items-center gap-2 text-[#ccc]">
22+
<span>🔧</span>
23+
<div>
24+
{message.status === "running" ? (
25+
<span>Running init hook...</span>
26+
) : message.status === "success" ? (
27+
<span>✅ Init hook completed successfully</span>
28+
) : (
29+
<span>
30+
Init hook exited with code {message.exitCode}. Workspace is ready, but some setup
31+
failed.
32+
</span>
33+
)}
34+
<div className="mt-0.5 font-mono text-[11px] text-[#888]">{message.hookPath}</div>
35+
</div>
36+
</div>
37+
{message.lines.length > 0 && (
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">
39+
{message.lines.join("\n")}
40+
</pre>
41+
)}
42+
</div>
43+
);
44+
});
45+
46+
InitMessage.displayName = "InitMessage";

src/components/Messages/MessageRenderer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ToolMessage } from "./ToolMessage";
66
import { ReasoningMessage } from "./ReasoningMessage";
77
import { StreamErrorMessage } from "./StreamErrorMessage";
88
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
9+
import { InitMessage } from "./InitMessage";
910

1011
interface MessageRendererProps {
1112
message: DisplayedMessage;
@@ -46,6 +47,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
4647
return <StreamErrorMessage message={message} className={className} />;
4748
case "history-hidden":
4849
return <HistoryHiddenMessage message={message} className={className} />;
50+
case "workspace-init":
51+
return <InitMessage message={message} className={className} />;
4952
default:
5053
console.error("don't know how to render message", message);
5154
return null;

src/constants/ipc-constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ export const IPC_CHANNELS = {
2525
WORKSPACE_REMOVE: "workspace:remove",
2626
WORKSPACE_RENAME: "workspace:rename",
2727
WORKSPACE_FORK: "workspace:fork",
28-
WORKSPACE_STREAM_META: "workspace:streamMeta",
2928
WORKSPACE_SEND_MESSAGE: "workspace:sendMessage",
3029
WORKSPACE_RESUME_STREAM: "workspace:resumeStream",
3130
WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream",

src/debug/agentSessionCli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Config } from "@/config";
88
import { HistoryService } from "@/services/historyService";
99
import { PartialService } from "@/services/partialService";
1010
import { AIService } from "@/services/aiService";
11+
import { InitStateManager } from "@/services/initStateManager";
1112
import { AgentSession, type AgentSessionChatEvent } from "@/services/agentSession";
1213
import {
1314
isCaughtUpMessage,
@@ -216,6 +217,7 @@ async function main(): Promise<void> {
216217
const historyService = new HistoryService(config);
217218
const partialService = new PartialService(config, historyService);
218219
const aiService = new AIService(config, historyService, partialService);
220+
const initStateManager = new InitStateManager(config);
219221
ensureProvidersConfig(config);
220222

221223
const session = new AgentSession({
@@ -224,6 +226,7 @@ async function main(): Promise<void> {
224226
historyService,
225227
partialService,
226228
aiService,
229+
initStateManager,
227230
});
228231

229232
session.ensureMetadata({

src/preload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const api: IPCApi = {
7373
openTerminal: (workspacePath) =>
7474
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
7575

76-
onChat: (workspaceId, callback) => {
76+
onChat: (workspaceId: string, callback) => {
7777
const channel = getChatChannel(workspaceId);
7878
const handler = (_event: unknown, data: WorkspaceChatMessage) => {
7979
callback(data);

src/services/agentSession.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Config } from "@/config";
66
import type { AIService } from "@/services/aiService";
77
import type { HistoryService } from "@/services/historyService";
88
import type { PartialService } from "@/services/partialService";
9+
import type { InitStateManager } from "@/services/initStateManager";
910
import type { WorkspaceMetadata } from "@/types/workspace";
1011
import type { WorkspaceChatMessage, StreamErrorMessage, SendMessageOptions } from "@/types/ipc";
1112
import type { SendMessageError } from "@/types/errors";
@@ -36,6 +37,7 @@ interface AgentSessionOptions {
3637
historyService: HistoryService;
3738
partialService: PartialService;
3839
aiService: AIService;
40+
initStateManager: InitStateManager;
3941
}
4042

4143
export class AgentSession {
@@ -44,14 +46,18 @@ export class AgentSession {
4446
private readonly historyService: HistoryService;
4547
private readonly partialService: PartialService;
4648
private readonly aiService: AIService;
49+
private readonly initStateManager: InitStateManager;
4750
private readonly emitter = new EventEmitter();
4851
private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> =
4952
[];
53+
private readonly initListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> =
54+
[];
5055
private disposed = false;
5156

5257
constructor(options: AgentSessionOptions) {
5358
assert(options, "AgentSession requires options");
54-
const { workspaceId, config, historyService, partialService, aiService } = options;
59+
const { workspaceId, config, historyService, partialService, aiService, initStateManager } =
60+
options;
5561

5662
assert(typeof workspaceId === "string", "workspaceId must be a string");
5763
const trimmedWorkspaceId = workspaceId.trim();
@@ -62,8 +68,10 @@ export class AgentSession {
6268
this.historyService = historyService;
6369
this.partialService = partialService;
6470
this.aiService = aiService;
71+
this.initStateManager = initStateManager;
6572

6673
this.attachAiListeners();
74+
this.attachInitListeners();
6775
}
6876

6977
dispose(): void {
@@ -75,6 +83,10 @@ export class AgentSession {
7583
this.aiService.off(event, handler as never);
7684
}
7785
this.aiListeners.length = 0;
86+
for (const { event, handler } of this.initListeners) {
87+
this.initStateManager.off(event, handler as never);
88+
}
89+
this.initListeners.length = 0;
7890
this.emitter.removeAllListeners();
7991
}
8092

@@ -121,13 +133,15 @@ export class AgentSession {
121133
private async emitHistoricalEvents(
122134
listener: (event: AgentSessionChatEvent) => void
123135
): Promise<void> {
136+
// Load chat history (persisted messages from chat.jsonl)
124137
const historyResult = await this.historyService.getHistory(this.workspaceId);
125138
if (historyResult.success) {
126139
for (const message of historyResult.data) {
127140
listener({ workspaceId: this.workspaceId, message });
128141
}
129142
}
130143

144+
// Check for interrupted streams (active streaming state)
131145
const streamInfo = this.aiService.getStreamInfo(this.workspaceId);
132146
const partial = await this.partialService.readPartial(this.workspaceId);
133147

@@ -137,6 +151,13 @@ export class AgentSession {
137151
listener({ workspaceId: this.workspaceId, message: partial });
138152
}
139153

154+
// Replay init state BEFORE caught-up (treat as historical data)
155+
// This ensures init events are buffered correctly by the frontend,
156+
// preserving their natural timing characteristics from the hook execution.
157+
await this.initStateManager.replayInit(this.workspaceId);
158+
159+
// Send caught-up after ALL historical data (including init events)
160+
// This signals frontend that replay is complete and future events are real-time
140161
listener({
141162
workspaceId: this.workspaceId,
142163
message: { type: "caught-up" },
@@ -405,7 +426,35 @@ export class AgentSession {
405426
this.aiService.on("error", errorHandler as never);
406427
}
407428

408-
private emitChatEvent(message: WorkspaceChatMessage): void {
429+
private attachInitListeners(): void {
430+
const forward = (event: string, handler: (payload: WorkspaceChatMessage) => void) => {
431+
const wrapped = (...args: unknown[]) => {
432+
const [payload] = args;
433+
if (
434+
typeof payload === "object" &&
435+
payload !== null &&
436+
"workspaceId" in payload &&
437+
(payload as { workspaceId: unknown }).workspaceId !== this.workspaceId
438+
) {
439+
return;
440+
}
441+
// Strip workspaceId from payload before forwarding (WorkspaceInitEvent doesn't include it)
442+
const { workspaceId: _, ...message } = payload as WorkspaceChatMessage & {
443+
workspaceId: string;
444+
};
445+
handler(message as WorkspaceChatMessage);
446+
};
447+
this.initListeners.push({ event, handler: wrapped });
448+
this.initStateManager.on(event, wrapped as never);
449+
};
450+
451+
forward("init-start", (payload) => this.emitChatEvent(payload));
452+
forward("init-output", (payload) => this.emitChatEvent(payload));
453+
forward("init-end", (payload) => this.emitChatEvent(payload));
454+
}
455+
456+
// Public method to emit chat events (used by init hooks and other workspace events)
457+
emitChatEvent(message: WorkspaceChatMessage): void {
409458
this.assertNotDisposed("emitChatEvent");
410459
this.emitter.emit("chat-event", {
411460
workspaceId: this.workspaceId,

0 commit comments

Comments
 (0)