Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ce3b2de
🤖 feat: Stream .cmux/init hook output on workspace creation
ammar-agent Oct 15, 2025
43c2a9b
docs: Add init hooks documentation
ammar-agent Oct 24, 2025
7d9320e
🤖 Fix init hook event replay order and test timing measurement
ammar-agent Oct 24, 2025
ddd538b
🤖 Fix init events not rendering incrementally in UI
ammar-agent Oct 24, 2025
ab27f5b
🤖 Add tests to prevent React.memo reference bugs in init messages
ammar-agent Oct 24, 2025
45584ad
🤖 Add debug logging for init message handling
ammar-agent Oct 24, 2025
e46c091
🤖 Complete debug logging for init events in handleMessage
ammar-agent Oct 24, 2025
faa8fca
🤖 Fix init events race condition by awaiting hook start
ammar-agent Oct 24, 2025
5502a4a
🤖 Cleanup: Remove debug logs and auto-dismiss from init hooks
ammar-agent Oct 24, 2025
04f81f9
🤖 Fix eslint errors in init hook code
ammar-agent Oct 24, 2025
8c5b0db
🤖 Quote init hook path to handle spaces and special characters
ammar-agent Oct 24, 2025
6ed9523
🤖 Remove unnecessary init event special case
ammar-agent Oct 24, 2025
c62e223
🤖 Update comment in isStreamEvent to reflect simplified logic
ammar-agent Oct 24, 2025
8c8a92a
🤖 Fix inaccurate comment about init event persistence
ammar-agent Oct 24, 2025
4d9e8d1
🤖 Fix init display bug - restore defensive checks
ammar-agent Oct 24, 2025
64fde2d
🤖 Fix init events being silently dropped in WorkspaceStore
ammar-agent Oct 24, 2025
1e65716
🤖 Buffer init events like stream events during replay
ammar-agent Oct 24, 2025
2c7b820
🤖 Add init event handlers to processStreamEvent
ammar-agent Oct 24, 2025
4fbc79b
🤖 Refactor: Make silent event drops structurally impossible
ammar-agent Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cmux/init
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

echo "Installing dependencies with bun..."
bun install
echo "Dependencies installed successfully!"

1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- [Workspaces](./workspaces.md)
- [Forking](./fork.md)
- [Init Hooks](./init-hooks.md)
- [Models](./models.md)
- [Keyboard Shortcuts](./keybinds.md)
- [Vim Mode](./vim-mode.md)
Expand Down
48 changes: 48 additions & 0 deletions docs/init-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Init Hooks

Add a `.cmux/init` executable script to your project root to run commands when creating new workspaces.

## Example

```bash
#!/bin/bash
set -e

bun install
bun run build
```

Make it executable:

```bash
chmod +x .cmux/init
```

## Behavior

- **Runs once** per workspace on creation
- **Streams output** to the workspace UI in real-time
- **Non-blocking** - workspace is immediately usable, even while hook runs
- **Exit codes preserved** - failures are logged but don't prevent workspace usage

The init script runs in the workspace directory with the workspace's environment.

## Use Cases

- Install dependencies (`npm install`, `bun install`, etc.)
- Run build steps
- Generate code or configs
- Set up databases or services
- Warm caches

## Output

Init output appears in a banner at the top of the workspace. Click to expand/collapse the log. The banner shows:

- Script path (`.cmux/init`)
- Status (running, success, or exit code on failure)
- Full stdout/stderr output

## Idempotency

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.
28 changes: 23 additions & 5 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,10 @@ const AIViewInner: React.FC<AIViewProps> = ({

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

if (!editCutoffHistoryId) {
Expand Down Expand Up @@ -277,8 +279,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
// When editing, find the cutoff point
const editCutoffHistoryId = editingMessage
? mergedMessages.find(
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
msg.historyId === editingMessage.id
)?.historyId
: undefined;

Expand Down Expand Up @@ -381,19 +385,33 @@ const AIViewInner: React.FC<AIViewProps> = ({
<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]">
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
<p className="mt-5 text-xs text-[#888]">
💡 Tip: Add a{" "}
<code className="rounded-[3px] bg-[#2d2d30] px-1.5 py-0.5 font-mono text-[11px] text-[#d7ba7d]">
.cmux/init
</code>{" "}
hook to your project to run setup commands
<br />
(e.g., install dependencies, build) when creating new workspaces
</p>
</div>
) : (
<>
{mergedMessages.map((msg) => {
const isAtCutoff =
editCutoffHistoryId !== undefined &&
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
msg.historyId === editCutoffHistoryId;

return (
<React.Fragment key={msg.id}>
<div
data-message-id={msg.type !== "history-hidden" ? msg.historyId : undefined}
data-message-id={
msg.type !== "history-hidden" && msg.type !== "workspace-init"
? msg.historyId
: undefined
}
>
<MessageRenderer
message={msg}
Expand Down
46 changes: 46 additions & 0 deletions src/components/Messages/InitMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";
import { cn } from "@/lib/utils";
import type { DisplayedMessage } from "@/types/message";

interface InitMessageProps {
message: Extract<DisplayedMessage, { type: "workspace-init" }>;
className?: string;
}

export const InitMessage = React.memo<InitMessageProps>(({ message, className }) => {
const isError = message.status === "error";

return (
<div
className={cn(
"flex flex-col gap-1.5 border-b p-3 font-mono text-xs text-[#ddd]",
isError ? "bg-[#3a1e1e] border-[#653737]" : "bg-[#1e2a3a] border-[#2f3f52]",
className
)}
>
<div className="flex items-center gap-2 text-[#ccc]">
<span>🔧</span>
<div>
{message.status === "running" ? (
<span>Running init hook...</span>
) : message.status === "success" ? (
<span>✅ Init hook completed successfully</span>
) : (
<span>
Init hook exited with code {message.exitCode}. Workspace is ready, but some setup
failed.
</span>
)}
<div className="mt-0.5 font-mono text-[11px] text-[#888]">{message.hookPath}</div>
</div>
</div>
{message.lines.length > 0 && (
<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">
{message.lines.join("\n")}
</pre>
)}
</div>
);
});

InitMessage.displayName = "InitMessage";
3 changes: 3 additions & 0 deletions src/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ToolMessage } from "./ToolMessage";
import { ReasoningMessage } from "./ReasoningMessage";
import { StreamErrorMessage } from "./StreamErrorMessage";
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
import { InitMessage } from "./InitMessage";

interface MessageRendererProps {
message: DisplayedMessage;
Expand Down Expand Up @@ -46,6 +47,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
return <StreamErrorMessage message={message} className={className} />;
case "history-hidden":
return <HistoryHiddenMessage message={message} className={className} />;
case "workspace-init":
return <InitMessage message={message} className={className} />;
default:
console.error("don't know how to render message", message);
return null;
Expand Down
1 change: 0 additions & 1 deletion src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export const IPC_CHANNELS = {
WORKSPACE_REMOVE: "workspace:remove",
WORKSPACE_RENAME: "workspace:rename",
WORKSPACE_FORK: "workspace:fork",
WORKSPACE_STREAM_META: "workspace:streamMeta",
WORKSPACE_SEND_MESSAGE: "workspace:sendMessage",
WORKSPACE_RESUME_STREAM: "workspace:resumeStream",
WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream",
Expand Down
3 changes: 3 additions & 0 deletions src/debug/agentSessionCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Config } from "@/config";
import { HistoryService } from "@/services/historyService";
import { PartialService } from "@/services/partialService";
import { AIService } from "@/services/aiService";
import { InitStateManager } from "@/services/initStateManager";
import { AgentSession, type AgentSessionChatEvent } from "@/services/agentSession";
import {
isCaughtUpMessage,
Expand Down Expand Up @@ -216,6 +217,7 @@ async function main(): Promise<void> {
const historyService = new HistoryService(config);
const partialService = new PartialService(config, historyService);
const aiService = new AIService(config, historyService, partialService);
const initStateManager = new InitStateManager(config);
ensureProvidersConfig(config);

const session = new AgentSession({
Expand All @@ -224,6 +226,7 @@ async function main(): Promise<void> {
historyService,
partialService,
aiService,
initStateManager,
});

session.ensureMetadata({
Expand Down
2 changes: 1 addition & 1 deletion src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const api: IPCApi = {
openTerminal: (workspacePath) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),

onChat: (workspaceId, callback) => {
onChat: (workspaceId: string, callback) => {
const channel = getChatChannel(workspaceId);
const handler = (_event: unknown, data: WorkspaceChatMessage) => {
callback(data);
Expand Down
53 changes: 51 additions & 2 deletions src/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Config } from "@/config";
import type { AIService } from "@/services/aiService";
import type { HistoryService } from "@/services/historyService";
import type { PartialService } from "@/services/partialService";
import type { InitStateManager } from "@/services/initStateManager";
import type { WorkspaceMetadata } from "@/types/workspace";
import type { WorkspaceChatMessage, StreamErrorMessage, SendMessageOptions } from "@/types/ipc";
import type { SendMessageError } from "@/types/errors";
Expand Down Expand Up @@ -36,6 +37,7 @@ interface AgentSessionOptions {
historyService: HistoryService;
partialService: PartialService;
aiService: AIService;
initStateManager: InitStateManager;
}

export class AgentSession {
Expand All @@ -44,14 +46,18 @@ export class AgentSession {
private readonly historyService: HistoryService;
private readonly partialService: PartialService;
private readonly aiService: AIService;
private readonly initStateManager: InitStateManager;
private readonly emitter = new EventEmitter();
private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> =
[];
private readonly initListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> =
[];
private disposed = false;

constructor(options: AgentSessionOptions) {
assert(options, "AgentSession requires options");
const { workspaceId, config, historyService, partialService, aiService } = options;
const { workspaceId, config, historyService, partialService, aiService, initStateManager } =
options;

assert(typeof workspaceId === "string", "workspaceId must be a string");
const trimmedWorkspaceId = workspaceId.trim();
Expand All @@ -62,8 +68,10 @@ export class AgentSession {
this.historyService = historyService;
this.partialService = partialService;
this.aiService = aiService;
this.initStateManager = initStateManager;

this.attachAiListeners();
this.attachInitListeners();
}

dispose(): void {
Expand All @@ -75,6 +83,10 @@ export class AgentSession {
this.aiService.off(event, handler as never);
}
this.aiListeners.length = 0;
for (const { event, handler } of this.initListeners) {
this.initStateManager.off(event, handler as never);
}
this.initListeners.length = 0;
this.emitter.removeAllListeners();
}

Expand Down Expand Up @@ -121,13 +133,15 @@ export class AgentSession {
private async emitHistoricalEvents(
listener: (event: AgentSessionChatEvent) => void
): Promise<void> {
// Load chat history (persisted messages from chat.jsonl)
const historyResult = await this.historyService.getHistory(this.workspaceId);
if (historyResult.success) {
for (const message of historyResult.data) {
listener({ workspaceId: this.workspaceId, message });
}
}

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

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

// Replay init state BEFORE caught-up (treat as historical data)
// This ensures init events are buffered correctly by the frontend,
// preserving their natural timing characteristics from the hook execution.
await this.initStateManager.replayInit(this.workspaceId);

// Send caught-up after ALL historical data (including init events)
// This signals frontend that replay is complete and future events are real-time
listener({
workspaceId: this.workspaceId,
message: { type: "caught-up" },
Expand Down Expand Up @@ -405,7 +426,35 @@ export class AgentSession {
this.aiService.on("error", errorHandler as never);
}

private emitChatEvent(message: WorkspaceChatMessage): void {
private attachInitListeners(): void {
const forward = (event: string, handler: (payload: WorkspaceChatMessage) => void) => {
const wrapped = (...args: unknown[]) => {
const [payload] = args;
if (
typeof payload === "object" &&
payload !== null &&
"workspaceId" in payload &&
(payload as { workspaceId: unknown }).workspaceId !== this.workspaceId
) {
return;
}
// Strip workspaceId from payload before forwarding (WorkspaceInitEvent doesn't include it)
const { workspaceId: _, ...message } = payload as WorkspaceChatMessage & {
workspaceId: string;
};
handler(message as WorkspaceChatMessage);
};
this.initListeners.push({ event, handler: wrapped });
this.initStateManager.on(event, wrapped as never);
};

forward("init-start", (payload) => this.emitChatEvent(payload));
forward("init-output", (payload) => this.emitChatEvent(payload));
forward("init-end", (payload) => this.emitChatEvent(payload));
}

// Public method to emit chat events (used by init hooks and other workspace events)
emitChatEvent(message: WorkspaceChatMessage): void {
this.assertNotDisposed("emitChatEvent");
this.emitter.emit("chat-event", {
workspaceId: this.workspaceId,
Expand Down
Loading