Skip to content

Commit 5080060

Browse files
committed
🤖 feat: VS Code-style extension system with global host architecture
Introduces a VS Code-style extension system for cmux, allowing users to extend functionality via custom extensions loaded from ~/.cmux/ext/. **Features:** - Extension discovery from global directory (~/.cmux/ext) - Single global extension host serves all workspaces (VS Code architecture) - Post-tool-use hook: Called after any tool execution in any workspace - Runtime interface: Provides workspace context (path, project, read/write files) - Scales efficiently: 50 workspaces = 1 process (not 50) **Architecture:** Main Process → spawns once → Extension Host (singleton) ↓ Map<workspaceId, Runtime> ↓ Extensions (loaded once) **ExtensionManager API:** - initializeGlobal() - Discovers extensions and spawns global host once at startup - registerWorkspace() - Creates runtime for workspace on first use - unregisterWorkspace() - Removes workspace runtime when workspace removed - shutdown() - Cleans up global host on app exit **Integration:** - AIService: Calls initializeGlobal() in constructor, registerWorkspace() on first message - IpcMain: Calls unregisterWorkspace() when workspace removed **Testing:** - All unit tests pass (14 tests: 9 discovery + 5 manager) - Integration tests pass (3 tests) - Static checks pass (typecheck, lint, format) _Generated with `cmux`_
1 parent e37906b commit 5080060

File tree

11 files changed

+1391
-2
lines changed

11 files changed

+1391
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ __pycache__
102102

103103
tmpfork
104104
.cmux-agent-cli
105+
.cmux/*.tmp.*
105106
storybook-static/
106107
*.tgz
107108
src/test-workspaces/

src/services/aiService.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getToolsForModel } from "@/utils/tools/tools";
1818
import { createRuntime } from "@/runtime/runtimeFactory";
1919
import { secretsToRecord } from "@/types/secrets";
2020
import type { CmuxProviderOptions } from "@/types/providerOptions";
21+
import { ExtensionManager } from "./extensions/extensionManager";
2122
import { log } from "./log";
2223
import {
2324
transformModelMessages,
@@ -112,6 +113,7 @@ export class AIService extends EventEmitter {
112113
private readonly initStateManager: InitStateManager;
113114
private readonly mockModeEnabled: boolean;
114115
private readonly mockScenarioPlayer?: MockScenarioPlayer;
116+
private readonly extensionManager: ExtensionManager;
115117

116118
constructor(
117119
config: Config,
@@ -127,7 +129,18 @@ export class AIService extends EventEmitter {
127129
this.historyService = historyService;
128130
this.partialService = partialService;
129131
this.initStateManager = initStateManager;
130-
this.streamManager = new StreamManager(historyService, partialService);
132+
133+
// Initialize extension manager
134+
this.extensionManager = new ExtensionManager();
135+
136+
// Initialize the global extension host
137+
void this.extensionManager.initializeGlobal().catch((error) => {
138+
log.error("Failed to initialize extension host:", error);
139+
});
140+
141+
// Initialize stream manager with extension manager
142+
this.streamManager = new StreamManager(historyService, partialService, this.extensionManager);
143+
131144
void this.ensureSessionsDir();
132145
this.setupStreamEventForwarding();
133146
this.mockModeEnabled = process.env.CMUX_MOCK_AI === "1";
@@ -556,6 +569,20 @@ export class AIService extends EventEmitter {
556569
const streamToken = this.streamManager.generateStreamToken();
557570
const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime);
558571

572+
// Register workspace with extension host (non-blocking)
573+
// Extensions need full workspace context including runtime and tempdir
574+
void this.extensionManager
575+
.registerWorkspace(
576+
workspaceId,
577+
metadata,
578+
metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir },
579+
runtimeTempDir
580+
)
581+
.catch((error) => {
582+
log.error(`Failed to register workspace ${workspaceId} with extension host:`, error);
583+
// Don't fail the stream on extension registration errors
584+
});
585+
559586
// Get model-specific tools with workspace path (correct for local or remote)
560587
const allTools = await getToolsForModel(
561588
modelString,
@@ -822,4 +849,11 @@ export class AIService extends EventEmitter {
822849
return Err(`Failed to delete workspace: ${message}`);
823850
}
824851
}
852+
853+
/**
854+
* Unregister a workspace from the extension host
855+
*/
856+
async unregisterWorkspace(workspaceId: string): Promise<void> {
857+
await this.extensionManager.unregisterWorkspace(workspaceId);
858+
}
825859
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* Extension Host Process
3+
*
4+
* This script runs as a separate Node.js process (spawned via fork()).
5+
* It receives IPC messages from the main cmux process, loads extensions once,
6+
* maintains a map of workspace runtimes, and dispatches hooks to extensions.
7+
*
8+
* A single shared extension host serves all workspaces (VS Code architecture).
9+
*/
10+
11+
import type { Runtime } from "../../runtime/Runtime";
12+
import type {
13+
Extension,
14+
ExtensionHostMessage,
15+
ExtensionHostResponse,
16+
ExtensionInfo,
17+
} from "../../types/extensions";
18+
19+
const workspaceRuntimes = new Map<string, Runtime>();
20+
const extensions: Array<{ id: string; module: Extension }> = [];
21+
22+
/**
23+
* Send a message to the parent process
24+
*/
25+
function sendMessage(message: ExtensionHostResponse): void {
26+
if (process.send) {
27+
process.send(message);
28+
}
29+
}
30+
31+
/**
32+
* Load an extension from its entrypoint path
33+
*/
34+
async function loadExtension(extInfo: ExtensionInfo): Promise<void> {
35+
try {
36+
// Dynamic import to load the extension module
37+
// Extensions must export a default object with hook handlers
38+
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-assignment -- Dynamic import required for user extensions
39+
const module = await import(extInfo.path);
40+
41+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- User-provided extension module
42+
if (!module.default) {
43+
throw new Error(`Extension ${extInfo.id} does not export a default object`);
44+
}
45+
46+
extensions.push({
47+
id: extInfo.id,
48+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- User-provided extension module
49+
module: module.default as Extension,
50+
});
51+
52+
console.log(`[ExtensionHost] Loaded extension: ${extInfo.id}`);
53+
} catch (error) {
54+
const errorMsg = error instanceof Error ? error.message : String(error);
55+
console.error(`[ExtensionHost] Failed to load extension ${extInfo.id}:`, errorMsg);
56+
sendMessage({
57+
type: "extension-load-error",
58+
id: extInfo.id,
59+
error: errorMsg,
60+
});
61+
}
62+
}
63+
64+
/**
65+
* Initialize the extension host (load extensions globally)
66+
*/
67+
async function handleInit(msg: Extract<ExtensionHostMessage, { type: "init" }>): Promise<void> {
68+
try {
69+
const { extensions: extensionList } = msg;
70+
71+
console.log(`[ExtensionHost] Initializing with ${extensionList.length} extension(s)`);
72+
73+
// Load all extensions once
74+
for (const extInfo of extensionList) {
75+
await loadExtension(extInfo);
76+
}
77+
78+
// Send ready message
79+
sendMessage({
80+
type: "ready",
81+
extensionCount: extensions.length,
82+
});
83+
84+
console.log(`[ExtensionHost] Ready with ${extensions.length} loaded extension(s)`);
85+
} catch (error) {
86+
console.error("[ExtensionHost] Failed to initialize:", error);
87+
process.exit(1);
88+
}
89+
}
90+
91+
/**
92+
* Register a workspace with the extension host
93+
*/
94+
async function handleRegisterWorkspace(
95+
msg: Extract<ExtensionHostMessage, { type: "register-workspace" }>
96+
): Promise<void> {
97+
try {
98+
const { workspaceId, runtimeConfig } = msg;
99+
100+
// Dynamically import createRuntime to avoid bundling issues
101+
// eslint-disable-next-line no-restricted-syntax -- Required in child process to avoid circular deps
102+
const { createRuntime } = await import("../../runtime/runtimeFactory");
103+
104+
// Create runtime for this workspace
105+
const runtime = createRuntime(runtimeConfig);
106+
workspaceRuntimes.set(workspaceId, runtime);
107+
108+
console.log(`[ExtensionHost] Registered workspace ${workspaceId}`);
109+
110+
// Send confirmation
111+
sendMessage({
112+
type: "workspace-registered",
113+
workspaceId,
114+
});
115+
} catch (error) {
116+
console.error(`[ExtensionHost] Failed to register workspace:`, error);
117+
}
118+
}
119+
120+
/**
121+
* Unregister a workspace from the extension host
122+
*/
123+
function handleUnregisterWorkspace(
124+
msg: Extract<ExtensionHostMessage, { type: "unregister-workspace" }>
125+
): void {
126+
const { workspaceId } = msg;
127+
128+
workspaceRuntimes.delete(workspaceId);
129+
console.log(`[ExtensionHost] Unregistered workspace ${workspaceId}`);
130+
131+
sendMessage({
132+
type: "workspace-unregistered",
133+
workspaceId,
134+
});
135+
}
136+
137+
/**
138+
* Dispatch post-tool-use hook to all extensions
139+
*/
140+
async function handlePostToolUse(
141+
msg: Extract<ExtensionHostMessage, { type: "post-tool-use" }>
142+
): Promise<void> {
143+
const { payload } = msg;
144+
145+
// Get runtime for this workspace
146+
const runtime = workspaceRuntimes.get(payload.workspaceId);
147+
if (!runtime) {
148+
console.warn(
149+
`[ExtensionHost] Runtime not found for workspace ${payload.workspaceId}, skipping hook`
150+
);
151+
sendMessage({
152+
type: "hook-complete",
153+
hookType: "post-tool-use",
154+
});
155+
return;
156+
}
157+
158+
// Dispatch to all extensions sequentially
159+
for (const { id, module } of extensions) {
160+
if (!module.onPostToolUse) {
161+
continue;
162+
}
163+
164+
try {
165+
// Call the extension's hook handler with runtime access
166+
await module.onPostToolUse({
167+
...payload,
168+
runtime,
169+
});
170+
} catch (error) {
171+
const errorMsg = error instanceof Error ? error.message : String(error);
172+
console.error(`[ExtensionHost] Extension ${id} threw error in onPostToolUse:`, errorMsg);
173+
sendMessage({
174+
type: "extension-error",
175+
extensionId: id,
176+
error: errorMsg,
177+
});
178+
}
179+
}
180+
181+
// Acknowledge completion
182+
sendMessage({
183+
type: "hook-complete",
184+
hookType: "post-tool-use",
185+
});
186+
}
187+
188+
/**
189+
* Handle shutdown request
190+
*/
191+
function handleShutdown(): void {
192+
console.log("[ExtensionHost] Shutting down");
193+
process.exit(0);
194+
}
195+
196+
/**
197+
* Main message handler
198+
*/
199+
process.on("message", (msg: ExtensionHostMessage) => {
200+
void (async () => {
201+
try {
202+
switch (msg.type) {
203+
case "init":
204+
await handleInit(msg);
205+
break;
206+
case "register-workspace":
207+
await handleRegisterWorkspace(msg);
208+
break;
209+
case "unregister-workspace":
210+
handleUnregisterWorkspace(msg);
211+
break;
212+
case "post-tool-use":
213+
await handlePostToolUse(msg);
214+
break;
215+
case "shutdown":
216+
handleShutdown();
217+
break;
218+
default:
219+
console.warn(`[ExtensionHost] Unknown message type:`, msg);
220+
}
221+
} catch (error) {
222+
console.error("[ExtensionHost] Error handling message:", error);
223+
}
224+
})();
225+
});
226+
227+
// Handle process errors
228+
process.on("uncaughtException", (error) => {
229+
console.error("[ExtensionHost] Uncaught exception:", error);
230+
process.exit(1);
231+
});
232+
233+
process.on("unhandledRejection", (reason) => {
234+
console.error("[ExtensionHost] Unhandled rejection:", reason);
235+
process.exit(1);
236+
});
237+
238+
console.log("[ExtensionHost] Process started, waiting for init message");

0 commit comments

Comments
 (0)