Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
585 changes: 493 additions & 92 deletions docs/AGENTS.md

Large diffs are not rendered by default.

21 changes: 10 additions & 11 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,17 @@ module.exports = {
"^@/(.*)$": "<rootDir>/src/$1",
"^chalk$": "<rootDir>/tests/__mocks__/chalk.js",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: {
target: "ES2020",
lib: ["ES2020", "DOM", "ES2022.Intl"],
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
globals: {
"ts-jest": {
tsconfig: {
target: "ES2020",
lib: ["ES2020", "DOM", "ES2022.Intl"],
module: "ESNext",
moduleResolution: "node",
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
],
},
},
// Transform ESM modules (like shiki) to CommonJS for Jest
transformIgnorePatterns: ["node_modules/(?!(shiki)/)"],
Expand Down
95 changes: 52 additions & 43 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
* Browser API client. Used when running cmux in server mode.
*/
import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants";
import { getImportMetaEnv } from "@/utils/importMeta";
import type { IPCApi } from "@/types/ipc";

// Backend URL - defaults to same origin, but can be overridden via VITE_BACKEND_URL
// This allows frontend (Vite :8080) to connect to backend (:3000) in dev mode
const API_BASE = import.meta.env.VITE_BACKEND_URL ?? window.location.origin;
const importMetaEnv = getImportMetaEnv<{ VITE_BACKEND_URL?: string }>();
const API_BASE = importMetaEnv.VITE_BACKEND_URL ?? window.location.origin;
const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://");

interface InvokeResponse<T> {
Expand Down Expand Up @@ -56,9 +58,10 @@ class WebSocketManager {
}

this.isConnecting = true;
this.ws = new WebSocket(`${WS_BASE}/ws`);
const ws = new WebSocket(`${WS_BASE}/ws`);
this.ws = ws;

this.ws.onopen = () => {
ws.onopen = () => {
console.log("WebSocket connected");
this.isConnecting = false;

Expand All @@ -69,7 +72,7 @@ class WebSocketManager {
}
};

this.ws.onmessage = (event) => {
ws.onmessage = (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data as string) as { channel: string; args: unknown[] };
const { channel, args } = parsed;
Expand All @@ -82,12 +85,12 @@ class WebSocketManager {
}
};

this.ws.onerror = (error) => {
ws.onerror = (error: Event) => {
console.error("WebSocket error:", error);
this.isConnecting = false;
};

this.ws.onclose = () => {
ws.onclose = () => {
console.log("WebSocket disconnected");
this.isConnecting = false;
this.ws = null;
Expand All @@ -100,47 +103,53 @@ class WebSocketManager {
}

subscribe(channel: string, workspaceId?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
console.log(
`[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId ?? "undefined"}`
);
this.ws.send(
JSON.stringify({
type: "subscribe",
channel: "workspace:chat",
workspaceId,
})
);
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
this.ws.send(
JSON.stringify({
type: "subscribe",
channel: "workspace:metadata",
})
);
}
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}

if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
console.log(
`[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId ?? "undefined"}`
);
ws.send(
JSON.stringify({
type: "subscribe",
channel: "workspace:chat",
workspaceId,
})
);
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
ws.send(
JSON.stringify({
type: "subscribe",
channel: "workspace:metadata",
})
);
}
}

unsubscribe(channel: string, workspaceId?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
this.ws.send(
JSON.stringify({
type: "unsubscribe",
channel: "workspace:chat",
workspaceId,
})
);
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
this.ws.send(
JSON.stringify({
type: "unsubscribe",
channel: "workspace:metadata",
})
);
}
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}

if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
ws.send(
JSON.stringify({
type: "unsubscribe",
channel: "workspace:chat",
workspaceId,
})
);
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
ws.send(
JSON.stringify({
type: "unsubscribe",
channel: "workspace:metadata",
})
);
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/components/hooks/useGitBranchDetails.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { getImportMetaEnv } from "@/utils/importMeta";
import { z } from "zod";
import type { GitStatus } from "@/types/workspace";
import { parseGitShowBranch, type GitCommit, type GitBranchHeader } from "@/utils/git/parseGitLog";
Expand All @@ -25,7 +26,8 @@ const SECTION_MARKERS = {
dirtyEnd: "__MUX_BRANCH_DATA__END_DIRTY_FILES__",
} as const;

const isDevelopment = import.meta.env.DEV;
const importMetaEnv = getImportMetaEnv<{ DEV?: boolean }>();
const isDevelopment = Boolean(importMetaEnv.DEV);

function debugAssert(condition: unknown, message: string): void {
if (!condition && isDevelopment) {
Expand Down
5 changes: 5 additions & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,8 @@ export const IPC_CHANNELS = {
// Helper functions for dynamic channels
export const getChatChannel = (workspaceId: string): string =>
`${IPC_CHANNELS.WORKSPACE_CHAT_PREFIX}${workspaceId}`;

// Event type constants for workspace init events
export const EVENT_TYPE_PREFIX_INIT = "init-";
export const EVENT_TYPE_INIT_OUTPUT = "init-output" as const;
export const EVENT_TYPE_INIT_END = "init-end" as const;
93 changes: 51 additions & 42 deletions src/debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,62 @@ import { listWorkspacesCommand } from "./list-workspaces";
import { costsCommand } from "./costs";
import { sendMessageCommand } from "./send-message";

const { positionals, values } = parseArgs({
args: process.argv.slice(2),
options: {
workspace: { type: "string", short: "w" },
drop: { type: "string", short: "d" },
limit: { type: "string", short: "l" },
all: { type: "boolean", short: "a" },
edit: { type: "string", short: "e" },
message: { type: "string", short: "m" },
},
allowPositionals: true,
});
async function main(): Promise<void> {
const { positionals, values } = parseArgs({
args: process.argv.slice(2),
options: {
workspace: { type: "string", short: "w" },
drop: { type: "string", short: "d" },
limit: { type: "string", short: "l" },
all: { type: "boolean", short: "a" },
edit: { type: "string", short: "e" },
message: { type: "string", short: "m" },
},
allowPositionals: true,
});

const command = positionals[0];
const command = positionals[0];

switch (command) {
case "list-workspaces":
listWorkspacesCommand();
break;
case "costs": {
const workspaceId = positionals[1];
if (!workspaceId) {
console.error("Error: workspace ID required");
console.log("Usage: bun debug costs <workspace-id>");
process.exit(1);
switch (command) {
case "list-workspaces":
listWorkspacesCommand();
break;
case "costs": {
const workspaceId = positionals[1];
if (!workspaceId) {
console.error("Error: workspace ID required");
console.log("Usage: bun debug costs <workspace-id>");
process.exit(1);
}
console.profile("costs");
await costsCommand(workspaceId);
console.profileEnd("costs");
break;
}
console.profile("costs");
await costsCommand(workspaceId);
console.profileEnd("costs");
break;
}
case "send-message": {
const workspaceId = positionals[1];
if (!workspaceId) {
console.error("Error: workspace ID required");
case "send-message": {
const workspaceId = positionals[1];
if (!workspaceId) {
console.error("Error: workspace ID required");
console.log(
"Usage: bun debug send-message <workspace-id> [--edit <message-id>] [--message <text>]"
);
process.exit(1);
}
sendMessageCommand(workspaceId, values.edit, values.message);
break;
}
default:
console.log("Usage:");
console.log(" bun debug list-workspaces");
console.log(" bun debug costs <workspace-id>");
console.log(
"Usage: bun debug send-message <workspace-id> [--edit <message-id>] [--message <text>]"
" bun debug send-message <workspace-id> [--edit <message-id>] [--message <text>]"
);
process.exit(1);
}
sendMessageCommand(workspaceId, values.edit, values.message);
break;
}
default:
console.log("Usage:");
console.log(" bun debug list-workspaces");
console.log(" bun debug costs <workspace-id>");
console.log(" bun debug send-message <workspace-id> [--edit <message-id>] [--message <text>]");
process.exit(1);
}

void main().catch((error) => {
console.error(error);
process.exit(1);
});
38 changes: 17 additions & 21 deletions src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,9 @@ import type {
} from "./Runtime";
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
import { NON_INTERACTIVE_ENV_VARS } from "../constants/env";
import { getBashPath } from "../utils/main/bashPath";
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "../constants/exitCodes";
import { listLocalBranches } from "../git";
import {
checkInitHookExists,
getInitHookPath,
createLineBufferedLoggers,
getInitHookEnv,
} from "./initHook";
import { checkInitHookExists, getInitHookPath, createLineBufferedLoggers } from "./initHook";
import { execAsync, DisposableProcess } from "../utils/disposableExec";
import { getProjectName } from "../utils/runtime/helpers";
import { getErrorMessage } from "../utils/errors";
Expand Down Expand Up @@ -62,13 +56,11 @@ export class LocalRuntime implements Runtime {
);
}

// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
// Windows doesn't have nice command, so just spawn bash directly
const isWindows = process.platform === "win32";
const bashPath = getBashPath();
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
// If niceness is specified, spawn nice directly to avoid escaping issues
const spawnCommand = options.niceness !== undefined ? "nice" : "bash";
const bashPath = "bash";
const spawnArgs =
options.niceness !== undefined && !isWindows
options.niceness !== undefined
? ["-n", options.niceness.toString(), bashPath, "-c", command]
: ["-c", command];

Expand Down Expand Up @@ -378,7 +370,10 @@ export class LocalRuntime implements Runtime {
const { projectPath, workspacePath, initLogger } = params;

try {
// Run .mux/init hook if it exists
// Note: sourceWorkspacePath is only used by SSH runtime (to copy workspace)
// Local runtime creates git worktrees which are instant, so we don't need it here

// Run .cmux/init hook if it exists
// Note: runInitHook calls logComplete() internally if hook exists
const hookExists = await checkInitHookExists(projectPath);
if (hookExists) {
Expand All @@ -400,7 +395,7 @@ export class LocalRuntime implements Runtime {
}

/**
* Run .mux/init hook if it exists and is executable
* Run .cmux/init hook if it exists and is executable
*/
private async runInitHook(
projectPath: string,
Expand All @@ -420,14 +415,9 @@ export class LocalRuntime implements Runtime {
const loggers = createLineBufferedLoggers(initLogger);

return new Promise<void>((resolve) => {
const bashPath = getBashPath();
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
const proc = spawn("bash", ["-c", `"${hookPath}"`], {
cwd: workspacePath,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...getInitHookEnv(projectPath, "local"),
},
});

proc.stdout.on("data", (data: Buffer) => {
Expand Down Expand Up @@ -601,7 +591,10 @@ export class LocalRuntime implements Runtime {
};
}

initLogger.logStep(`Detected source branch: ${sourceBranch}`);

// Use createWorkspace with sourceBranch as trunk to fork from source branch
// For local workspaces (worktrees), this is instant - no init needed
const createResult = await this.createWorkspace({
projectPath,
branchName: newWorkspaceName,
Expand All @@ -617,9 +610,12 @@ export class LocalRuntime implements Runtime {
};
}

initLogger.logStep("Workspace forked successfully");

return {
success: true,
workspacePath: createResult.workspacePath,
sourceWorkspacePath,
sourceBranch,
};
} catch (error) {
Expand Down
Loading