Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ __pycache__

tmpfork
.cmux-agent-cli
.cmux/*.tmp.*
storybook-static/
*.tgz
src/test-workspaces/
Expand Down
14 changes: 14 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,20 @@ await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, bra
- Verifying filesystem state (like checking if files exist) after IPC operations complete
- Loading existing data to avoid expensive API calls in test setup

### Testing without Mocks (preferred)

- Prefer exercising real behavior over substituting test doubles. Do not stub `child_process`, `fs`, or discovery logic.
- **Use async fs operations (`fs/promises`) in tests, never sync fs**. This keeps tests fast and allows parallelization.
- **Use `test.concurrent()` for unit tests** to enable parallel execution. Avoid global variables in test files—use local variables in each test or `beforeEach` to ensure test isolation.
- Use temporary directories and real processes in unit tests where feasible. Clean up with `await fs.rm(temp, { recursive: true, force: true })` in async `afterEach`.
- For extension system tests:
- Spawn the real global extension host via `ExtensionManager.initializeGlobal()`.
- Create real on-disk extensions in a temp `~/.cmux/ext` or project `.cmux/ext` folder.
- Register/unregister real workspaces and verify through actual tool execution.
- Integration tests must go through real IPC. Use the test harness's `mockIpcRenderer.invoke()` to traverse the production IPC path (this is a façade, not a Jest mock).
- Avoid spies and partial mocks. If a mock seems necessary, consider fixing the test harness or refactoring code to make the behavior testable without mocks.
- Acceptable exceptions: isolating nondeterminism (e.g., time) or external network calls. Prefer dependency injection with in-memory fakes over broad module mocks.

If IPC is hard to test, fix the test infrastructure or IPC layer, don't work around it by bypassing IPC.

## Command Palette (Cmd+Shift+P)
Expand Down
48 changes: 44 additions & 4 deletions src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { sanitizeToolInputs } from "@/utils/messages/sanitizeToolInput";
import type { Result } from "@/types/result";
import { Ok, Err } from "@/types/result";
import type { WorkspaceMetadata } from "@/types/workspace";
import type { RuntimeConfig } from "@/types/runtime";

import type { CmuxMessage, CmuxTextPart } from "@/types/message";
import { createCmuxMessage } from "@/types/message";
Expand All @@ -18,6 +19,7 @@ import { getToolsForModel } from "@/utils/tools/tools";
import { createRuntime } from "@/runtime/runtimeFactory";
import { secretsToRecord } from "@/types/secrets";
import type { CmuxProviderOptions } from "@/types/providerOptions";
import { ExtensionManager } from "./extensions/extensionManager";
import { log } from "./log";
import {
transformModelMessages,
Expand Down Expand Up @@ -112,6 +114,7 @@ export class AIService extends EventEmitter {
private readonly initStateManager: InitStateManager;
private readonly mockModeEnabled: boolean;
private readonly mockScenarioPlayer?: MockScenarioPlayer;
private readonly extensionManager: ExtensionManager;

constructor(
config: Config,
Expand All @@ -127,7 +130,18 @@ export class AIService extends EventEmitter {
this.historyService = historyService;
this.partialService = partialService;
this.initStateManager = initStateManager;
this.streamManager = new StreamManager(historyService, partialService);

// Initialize extension manager
this.extensionManager = new ExtensionManager();

// Initialize the global extension host
void this.extensionManager.initializeGlobal().catch((error) => {
log.error("Failed to initialize extension host:", error);
});

// Initialize stream manager with extension manager
this.streamManager = new StreamManager(historyService, partialService, this.extensionManager);

void this.ensureSessionsDir();
this.setupStreamEventForwarding();
this.mockModeEnabled = process.env.CMUX_MOCK_AI === "1";
Expand Down Expand Up @@ -396,6 +410,13 @@ export class AIService extends EventEmitter {
* @param mode Optional mode name - affects system message via Mode: sections in AGENTS.md
* @returns Promise that resolves when streaming completes or fails
*/

/**
* Get runtime config for a workspace, falling back to default local config
*/
private getWorkspaceRuntimeConfig(metadata: WorkspaceMetadata): RuntimeConfig {
return metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir };
}
async streamMessage(
messages: CmuxMessage[],
workspaceId: string,
Expand Down Expand Up @@ -526,9 +547,7 @@ export class AIService extends EventEmitter {
}

// Get workspace path - handle both worktree and in-place modes
const runtime = createRuntime(
metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }
);
const runtime = createRuntime(this.getWorkspaceRuntimeConfig(metadata));
// In-place workspaces (CLI/benchmarks) have projectPath === name
// Use path directly instead of reconstructing via getWorkspacePath
const isInPlace = metadata.projectPath === metadata.name;
Expand Down Expand Up @@ -556,6 +575,20 @@ export class AIService extends EventEmitter {
const streamToken = this.streamManager.generateStreamToken();
const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime);

// Register workspace with extension host (non-blocking)
// Extensions need full workspace context including runtime and tempdir
void this.extensionManager
.registerWorkspace(
workspaceId,
metadata,
this.getWorkspaceRuntimeConfig(metadata),
runtimeTempDir
)
.catch((error) => {
log.error(`Failed to register workspace ${workspaceId} with extension host:`, error);
// Don't fail the stream on extension registration errors
});

// Get model-specific tools with workspace path (correct for local or remote)
const allTools = await getToolsForModel(
modelString,
Expand Down Expand Up @@ -822,4 +855,11 @@ export class AIService extends EventEmitter {
return Err(`Failed to delete workspace: ${message}`);
}
}

/**
* Unregister a workspace from the extension host
*/
async unregisterWorkspace(workspaceId: string): Promise<void> {
await this.extensionManager.unregisterWorkspace(workspaceId);
}
}
238 changes: 238 additions & 0 deletions src/services/extensions/extensionHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Extension Host Process
*
* This script runs as a separate Node.js process (spawned via fork()).
* It receives IPC messages from the main cmux process, loads extensions once,
* maintains a map of workspace runtimes, and dispatches hooks to extensions.
*
* A single shared extension host serves all workspaces (VS Code architecture).
*/

import type { Runtime } from "../../runtime/Runtime";
import type {
Extension,
ExtensionHostMessage,
ExtensionHostResponse,
ExtensionInfo,
} from "../../types/extensions";

const workspaceRuntimes = new Map<string, Runtime>();
const extensions: Array<{ id: string; module: Extension }> = [];

/**
* Send a message to the parent process
*/
function sendMessage(message: ExtensionHostResponse): void {
if (process.send) {
process.send(message);
}
}

/**
* Load an extension from its entrypoint path
*/
async function loadExtension(extInfo: ExtensionInfo): Promise<void> {
try {
// Dynamic import to load the extension module
// Extensions must export a default object with hook handlers
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-assignment -- Dynamic import required for user extensions
const module = await import(extInfo.path);

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- User-provided extension module
if (!module.default) {
throw new Error(`Extension ${extInfo.id} does not export a default object`);
}

extensions.push({
id: extInfo.id,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- User-provided extension module
module: module.default as Extension,
});

console.log(`[ExtensionHost] Loaded extension: ${extInfo.id}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ExtensionHost] Failed to load extension ${extInfo.id}:`, errorMsg);
sendMessage({
type: "extension-load-error",
id: extInfo.id,
error: errorMsg,
});
}
}

/**
* Initialize the extension host (load extensions globally)
*/
async function handleInit(msg: Extract<ExtensionHostMessage, { type: "init" }>): Promise<void> {
try {
const { extensions: extensionList } = msg;

console.log(`[ExtensionHost] Initializing with ${extensionList.length} extension(s)`);

// Load all extensions once
for (const extInfo of extensionList) {
await loadExtension(extInfo);
}

// Send ready message
sendMessage({
type: "ready",
extensionCount: extensions.length,
});

console.log(`[ExtensionHost] Ready with ${extensions.length} loaded extension(s)`);
} catch (error) {
console.error("[ExtensionHost] Failed to initialize:", error);
process.exit(1);
}
}

/**
* Register a workspace with the extension host
*/
async function handleRegisterWorkspace(
msg: Extract<ExtensionHostMessage, { type: "register-workspace" }>
): Promise<void> {
try {
const { workspaceId, runtimeConfig } = msg;

// Dynamically import createRuntime to avoid bundling issues
// eslint-disable-next-line no-restricted-syntax -- Required in child process to avoid circular deps
const { createRuntime } = await import("../../runtime/runtimeFactory");

// Create runtime for this workspace
const runtime = createRuntime(runtimeConfig);
workspaceRuntimes.set(workspaceId, runtime);

console.log(`[ExtensionHost] Registered workspace ${workspaceId}`);

// Send confirmation
sendMessage({
type: "workspace-registered",
workspaceId,
});
} catch (error) {
console.error(`[ExtensionHost] Failed to register workspace:`, error);
}
}

/**
* Unregister a workspace from the extension host
*/
function handleUnregisterWorkspace(
msg: Extract<ExtensionHostMessage, { type: "unregister-workspace" }>
): void {
const { workspaceId } = msg;

workspaceRuntimes.delete(workspaceId);
console.log(`[ExtensionHost] Unregistered workspace ${workspaceId}`);

sendMessage({
type: "workspace-unregistered",
workspaceId,
});
}

/**
* Dispatch post-tool-use hook to all extensions
*/
async function handlePostToolUse(
msg: Extract<ExtensionHostMessage, { type: "post-tool-use" }>
): Promise<void> {
const { payload } = msg;

// Get runtime for this workspace
const runtime = workspaceRuntimes.get(payload.workspaceId);
if (!runtime) {
console.warn(
`[ExtensionHost] Runtime not found for workspace ${payload.workspaceId}, skipping hook`
);
sendMessage({
type: "hook-complete",
hookType: "post-tool-use",
});
return;
}

// Dispatch to all extensions sequentially
for (const { id, module } of extensions) {
if (!module.onPostToolUse) {
continue;
}

try {
// Call the extension's hook handler with runtime access
await module.onPostToolUse({
...payload,
runtime,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ExtensionHost] Extension ${id} threw error in onPostToolUse:`, errorMsg);
sendMessage({
type: "extension-error",
extensionId: id,
error: errorMsg,
});
}
}

// Acknowledge completion
sendMessage({
type: "hook-complete",
hookType: "post-tool-use",
});
}

/**
* Handle shutdown request
*/
function handleShutdown(): void {
console.log("[ExtensionHost] Shutting down");
process.exit(0);
}

/**
* Main message handler
*/
process.on("message", (msg: ExtensionHostMessage) => {
void (async () => {
try {
switch (msg.type) {
case "init":
await handleInit(msg);
break;
case "register-workspace":
await handleRegisterWorkspace(msg);
break;
case "unregister-workspace":
handleUnregisterWorkspace(msg);
break;
case "post-tool-use":
await handlePostToolUse(msg);
break;
case "shutdown":
handleShutdown();
break;
default:
console.warn(`[ExtensionHost] Unknown message type:`, msg);
}
} catch (error) {
console.error("[ExtensionHost] Error handling message:", error);
}
})();
});

// Handle process errors
process.on("uncaughtException", (error) => {
console.error("[ExtensionHost] Uncaught exception:", error);
process.exit(1);
});

process.on("unhandledRejection", (reason) => {
console.error("[ExtensionHost] Unhandled rejection:", reason);
process.exit(1);
});

console.log("[ExtensionHost] Process started, waiting for init message");
Loading
Loading