agentHost: hello world from server

Exposes --agent-host-port/--agent-host-path to specify an additional
port for them to listen on. This runs as a separate process mirroring
the architecture from main.

Does a 'hello world', I need to keep testing it some more. I honestly
don't know a lot about the import changes Opus is doing here. Without
them, there is an error:

```
import { CopilotClient } from "@github/copilot-sdk";
         ^^^^^^^^^^^^^
SyntaxError: The requested module '@github/copilot-sdk' does not provide an export named 'CopilotClient'
```

This was Copilot's explanation for why they're needed here but not in Electron:

>The agent host process itself didn't change — the issue is the **module resolution environment** differs between Electron and the server.
>
>**Electron (desktop):** `ElectronAgentHostStarter` spawns a utility process that inherits the main process's module resolution. It resolves packages from the **root** node_modules, where `@github/copilot-sdk`, `ws`, etc. are all installed. Everything just works.
>
>**Server:** `NodeAgentHostStarter` spawns a child via `bootstrap-fork.js`, which registers a custom ESM resolver hook (bootstrap-import.ts) that **redirects** all module lookups to node_modules instead of root node_modules. That's the `[bootstrap-import] Initialized node_modules redirector for: ...\remote\node_modules` log line. The remote folder has its own package.json with a curated set of server-specific dependencies.
>
>The problems were:
>1. **Missing deps** — `@github/copilot-sdk`, `@github/copilot`, and `ws` weren't in package.json because the agent host had never run in the server context before
>2. **Resolver bugs** — bootstrap-import.ts was written when all remote deps were CJS. It hardcoded `format: 'commonjs'`, didn't handle `exports` maps, didn't handle `.mjs` files, and didn't resolve subpath imports like `vscode-jsonrpc/node`. These are pre-existing limitations that never mattered until now because no server component previously depended on ESM-only npm packages.
>
>So in short: the agent host code is identical — it's the server's module resolution plumbing that needed updating to support the ESM packages the agent host depends on.

cc @bpasero as the expert in this area
This commit is contained in:
Connor Peet
2026-03-12 09:00:40 -07:00
parent 076b448ec0
commit 3e15c2da57
12 changed files with 497 additions and 171 deletions

169
remote/package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.23",
"@github/copilot": "^1.0.4-0",
"@github/copilot-sdk": "^0.1.32",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@parcel/watcher": "^2.5.6",
@@ -44,6 +46,7 @@
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "^9.3.2",
"ws": "^8.19.0",
"yauzl": "^3.0.0",
"yazl": "^2.4.3"
}
@@ -77,6 +80,142 @@
"node": ">=18"
}
},
"node_modules/@github/copilot": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4.tgz",
"integrity": "sha512-IpPg+zYplLu4F4lmatEDdR/1Y/jJ9cGWt89m3K3H4YSfYrZ5Go4UlM28llulYCG7sVdQeIGauQN1/KiBI/Rocg==",
"license": "SEE LICENSE IN LICENSE.md",
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.4",
"@github/copilot-darwin-x64": "1.0.4",
"@github/copilot-linux-arm64": "1.0.4",
"@github/copilot-linux-x64": "1.0.4",
"@github/copilot-win32-arm64": "1.0.4",
"@github/copilot-win32-x64": "1.0.4"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-/YGGhv6cp0ItolsF0HsLq2KmesA4atn0IEYApBs770fzJ8OP2pkOEzrxo3gWU3wc7fHF2uDB1RrJEZ7QSFLdEQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-arm64": "copilot"
}
},
"node_modules/@github/copilot-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4.tgz",
"integrity": "sha512-gwn2QjZbc1SqPVSAtDMesU1NopyHZT8Qsn37xPfznpV9s94KVyX4TTiDZaUwfnI0wr8kVHBL46RPLNz6I8kR9A==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-x64": "copilot"
}
},
"node_modules/@github/copilot-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4.tgz",
"integrity": "sha512-92vzHKxN55BpI76sP/5fXIXfat1gzAhsq4bNLqLENGfZyMP/25OiVihCZuQHnvxzXaHBITFGUvtxfdll2kbcng==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-arm64": "copilot"
}
},
"node_modules/@github/copilot-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4.tgz",
"integrity": "sha512-wQvpwf4/VMTnSmWyYzq07Xg18Vxg7aZ5NVkkXqlLTuXRASW0kvCCb5USEtXHHzR7E6rJztkhCjFRE1bZW8jAGw==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "0.1.32",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz",
"integrity": "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA==",
"license": "MIT",
"dependencies": {
"@github/copilot": "^1.0.2",
"vscode-jsonrpc": "^8.2.1",
"zod": "^4.3.6"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@github/copilot-sdk/node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@github/copilot-win32-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4.tgz",
"integrity": "sha512-zOvD/5GVxDf0ZdlTkK+m55Vs55xuHNmACX50ZO2N23ZGG2dmkdS4mkruL59XB5ISgrOfeqvnqrwTFHbmPZtLfw==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-arm64": "copilot.exe"
}
},
"node_modules/@github/copilot-win32-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4.tgz",
"integrity": "sha512-yQenHMdkV0b77mF6aLM60TuwtNZ592TluptVDF+80Sj2zPfCpLyvrRh2FCIHRtuwTy4BfxETh2hCFHef8E6IOw==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"node_modules/@microsoft/1ds-core-js": {
"version": "3.2.13",
"resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-3.2.13.tgz",
@@ -1455,6 +1594,15 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-oniguruma": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
@@ -1482,6 +1630,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@@ -4,6 +4,8 @@
"private": true,
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.23",
"@github/copilot": "^1.0.4-0",
"@github/copilot-sdk": "^0.1.32",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@parcel/watcher": "^2.5.6",
@@ -39,6 +41,7 @@
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "^9.3.2",
"ws": "^8.19.0",
"yauzl": "^3.0.0",
"yazl": "^2.4.3"
},

View File

@@ -17,6 +17,8 @@ import { join } from 'node:path';
// SEE https://nodejs.org/docs/latest/api/module.html#initialize
const _specifierToUrl: Record<string, string> = {};
const _specifierToFormat: Record<string, string> = {};
const _nodeModulesPath: string[] = [];
export async function initialize(injectPath: string): Promise<void> {
// populate mappings
@@ -24,19 +26,40 @@ export async function initialize(injectPath: string): Promise<void> {
const injectPackageJSONPath = fileURLToPath(new URL('../package.json', pathToFileURL(injectPath)));
const packageJSON = JSON.parse(String(await promises.readFile(injectPackageJSONPath)));
// Remember the node_modules root for subpath resolution
_nodeModulesPath.push(join(injectPackageJSONPath, `../node_modules`));
for (const [name] of Object.entries(packageJSON.dependencies)) {
try {
const path = join(injectPackageJSONPath, `../node_modules/${name}/package.json`);
let { main } = JSON.parse(String(await promises.readFile(path)));
const pkgJson = JSON.parse(String(await promises.readFile(path)));
// Determine the entry point: prefer exports["."].import for ESM, then main
let main: string | undefined;
if (pkgJson.exports?.['.']) {
const dotExport = pkgJson.exports['.'];
if (typeof dotExport === 'string') {
main = dotExport;
} else if (typeof dotExport === 'object' && dotExport !== null) {
main = dotExport.import ?? dotExport.default;
}
}
if (typeof main !== 'string') {
main = typeof pkgJson.main === 'string' ? pkgJson.main : undefined;
}
if (!main) {
main = 'index.js';
}
if (!main.endsWith('.js')) {
if (!main.endsWith('.js') && !main.endsWith('.mjs') && !main.endsWith('.cjs')) {
main += '.js';
}
const mainPath = join(injectPackageJSONPath, `../node_modules/${name}/${main}`);
_specifierToUrl[name] = pathToFileURL(mainPath).href;
// Determine module format: .mjs is always ESM, .cjs always CJS, otherwise check type field
_specifierToFormat[name] = main.endsWith('.mjs') || pkgJson.type === 'module' ? 'module'
: main.endsWith('.cjs') ? 'commonjs'
: 'commonjs';
} catch (err) {
console.error(name);
@@ -52,12 +75,30 @@ export async function resolve(specifier: string | number, context: unknown, next
const newSpecifier = _specifierToUrl[specifier];
if (newSpecifier !== undefined) {
return {
format: 'commonjs',
format: _specifierToFormat[specifier] ?? 'commonjs',
shortCircuit: true,
url: newSpecifier
};
}
// Handle subpath imports (e.g., 'vscode-jsonrpc/node') by resolving
// through the redirected node_modules directory.
if (_nodeModulesPath.length > 0 && typeof specifier === 'string' && !specifier.startsWith('.') && !specifier.startsWith('node:')) {
for (const nmPath of _nodeModulesPath) {
// Try resolving the specifier as a file inside node_modules
let candidate = join(nmPath, specifier);
if (!candidate.endsWith('.js')) {
candidate += '.js';
}
try {
await promises.access(candidate);
return nextResolve(pathToFileURL(candidate).href, context);
} catch {
// not found, let next resolver handle it
}
}
}
// Defer to the next hook in the chain, which would be the
// Node.js default resolve if this is the last user-specified loader.
return nextResolve(specifier, context);

View File

@@ -25,7 +25,7 @@ perf.mark('code/server/start');
// Do a quick parse to determine if a server or the cli needs to be started
const parsedArgs = minimist(process.argv.slice(2), {
boolean: ['start-server', 'list-extensions', 'print-ip-address', 'help', 'version', 'accept-server-license-terms', 'update-extensions'],
string: ['install-extension', 'install-builtin-extension', 'uninstall-extension', 'locate-extension', 'socket-path', 'host', 'port', 'compatibility'],
string: ['install-extension', 'install-builtin-extension', 'uninstall-extension', 'locate-extension', 'socket-path', 'host', 'port', 'compatibility', 'agent-host-port', 'agent-host-path'],
alias: { help: 'h', version: 'v' }
});
['host', 'port', 'accept-server-license-terms'].forEach(e => {

View File

@@ -8,12 +8,15 @@ import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.c
import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js';
import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js';
import { DisposableStore } from '../../../base/common/lifecycle.js';
import { AgentHostIpcChannels } from '../common/agentService.js';
import { AgentHostIpcChannels, type AgentProvider } from '../common/agentService.js';
import { SessionStatus } from '../common/state/sessionState.js';
import { AgentService } from './agentService.js';
import { CopilotAgent } from './copilot/copilotAgent.js';
import { ProtocolServerHandler, type IProtocolSideEffectHandler } from './protocolServerHandler.js';
import { WebSocketProtocolServer } from './webSocketTransport.js';
import { NativeEnvironmentService } from '../../environment/node/environmentService.js';
import { parseArgs, OPTIONS } from '../../environment/node/argv.js';
import { getLogLevel } from '../../log/common/log.js';
import { getLogLevel, ILogService } from '../../log/common/log.js';
import { LogService } from '../../log/common/logService.js';
import { LoggerService } from '../../log/node/loggerService.js';
import { LoggerChannel } from '../../log/common/logIpc.js';
@@ -24,6 +27,8 @@ import { localize } from '../../../nls.js';
// Entry point for the agent host utility process.
// Sets up IPC, logging, and registers agent providers (Copilot).
// When VSCODE_AGENT_HOST_PORT or VSCODE_AGENT_HOST_SOCKET_PATH env vars
// are set, also starts a WebSocket server for external clients.
startAgentHost();
@@ -59,9 +64,81 @@ function startAgentHost(): void {
const agentChannel = ProxyChannel.fromService(agentService, disposables);
server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel);
// Start WebSocket server for external clients if configured
startWebSocketServer(agentService, logService, disposables);
process.once('exit', () => {
agentService.dispose();
logService.dispose();
disposables.dispose();
});
}
/**
* When the parent process passes WebSocket configuration via environment
* variables, start a protocol server that external clients can connect to.
* This reuses the same {@link AgentService} and {@link SessionStateManager}
* that the IPC channel uses, so both IPC and WebSocket clients share state.
*/
async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore): Promise<void> {
const port = process.env['VSCODE_AGENT_HOST_PORT'];
const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH'];
if (!port && !socketPath) {
return;
}
const connectionToken = process.env['VSCODE_AGENT_HOST_CONNECTION_TOKEN'];
const host = process.env['VSCODE_AGENT_HOST_HOST'] || 'localhost';
const wsServer = disposables.add(await WebSocketProtocolServer.create(
socketPath
? {
socketPath,
connectionTokenValidate: connectionToken
? (token) => token === connectionToken
: undefined,
}
: {
port: parseInt(port!, 10),
host,
connectionTokenValidate: connectionToken
? (token) => token === connectionToken
: undefined,
},
logService,
));
// Create a side-effect handler that delegates to AgentService
const sideEffects: IProtocolSideEffectHandler = {
handleAction(action) {
agentService.dispatchAction(action, 'ws-server', 0);
},
async handleCreateSession(command) {
await agentService.createSession({
provider: command.provider as AgentProvider | undefined,
model: command.model,
workingDirectory: command.workingDirectory,
});
},
handleDisposeSession(session) {
agentService.disposeSession(session);
},
async handleListSessions() {
const sessions = await agentService.listSessions();
return sessions.map(s => ({
resource: s.session,
provider: '' as AgentProvider,
title: s.summary ?? 'Session',
status: SessionStatus.Idle,
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
}));
},
};
disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService));
const listenTarget = socketPath ?? `${host}:${port}`;
logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`);
}

View File

@@ -7,7 +7,6 @@
// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port <port>] [--enable-mock-agent]
import { DisposableStore } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
import { NativeEnvironmentService } from '../../environment/node/environmentService.js';
import { INativeEnvironmentService } from '../../environment/common/environment.js';
@@ -20,17 +19,11 @@ import { IProductService } from '../../product/common/productService.js';
import { InstantiationService } from '../../instantiation/common/instantiationService.js';
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
import { CopilotAgent } from './copilot/copilotAgent.js';
import { AgentSession, type AgentProvider, type IAgent } from '../common/agentService.js';
import { SessionStateManager } from './sessionStateManager.js';
import { type AgentProvider } from '../common/agentService.js';
import { SessionStatus } from '../common/state/sessionState.js';
import { AgentService } from './agentService.js';
import { WebSocketProtocolServer } from './webSocketTransport.js';
import { ProtocolServerHandler, type IProtocolSideEffectHandler } from './protocolServerHandler.js';
import { mapProgressEventToAction } from './agentEventMapper.js';
import {
ISessionModelInfo,
SessionStatus, type ISessionSummary
} from '../common/state/sessionState.js';
import type { ISessionAction } from '../common/state/sessionActions.js';
import type { ICreateSessionParams } from '../common/state/sessionProtocol.js';
// ---- Options ----------------------------------------------------------------
@@ -52,7 +45,7 @@ function parseServerOptions(): IServerOptions {
// ---- Main -------------------------------------------------------------------
function main(): void {
async function main(): Promise<void> {
const options = parseServerOptions();
const disposables = new DisposableStore();
@@ -77,51 +70,10 @@ function main(): void {
logService.info('[AgentHostServer] Starting standalone agent host server');
// Create state manager
const stateManager = disposables.add(new SessionStateManager(logService));
// Agent registry — maps provider id to agent instance
const agents = new Map<AgentProvider, IAgent>();
function registerAgent(agent: IAgent): void {
agents.set(agent.id, agent);
disposables.add(agent.onDidSessionProgress(e => {
const turnId = stateManager.getActiveTurnId(e.session);
if (turnId) {
const action = mapProgressEventToAction(e, e.session, turnId);
if (action) {
stateManager.dispatchServerAction(action);
}
}
}));
// Publish agent to root state (models fetched async)
publishAgentsToRootState();
logService.info(`[AgentHostServer] Registered agent: ${agent.id}`);
}
async function publishAgentsToRootState(): Promise<void> {
const agentInfos = await Promise.all([...agents.values()].map(async a => {
const d = a.getDescriptor();
let models: ISessionModelInfo[];
try {
const rawModels = await a.listModels();
models = rawModels.map(m => ({
id: m.id, provider: m.provider, name: m.name,
maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision,
policyState: m.policyState,
}));
} catch {
models = [];
}
return { provider: d.provider, displayName: d.displayName, description: d.description, models };
}));
stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents: agentInfos });
}
function getAgent(session: URI): IAgent | undefined {
const provider = AgentSession.provider(session);
return provider ? agents.get(provider) : agents.values().next().value;
}
// Create agent service — handles agent registration, session lifecycle,
// event mapping, and state management (reuses the same logic as the
// utility-process entry point in agentHostMain.ts).
const agentService = disposables.add(new AgentService(logService));
// Register agents
if (!options.quiet) {
@@ -135,121 +87,52 @@ function main(): void {
services.set(ILogService, logService);
const instantiationService = new InstantiationService(services);
const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent));
registerAgent(copilotAgent);
agentService.registerProvider(copilotAgent);
}
if (options.enableMockAgent) {
// Dynamic import to avoid bundling test code in production
import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => {
const mockAgent = disposables.add(new ScriptedMockAgent());
registerAgent(mockAgent);
agentService.registerProvider(mockAgent);
}).catch(err => {
logService.error('[AgentHostServer] Failed to load mock agent', err);
});
}
// WebSocket server
const wsServer = disposables.add(new WebSocketProtocolServer(options.port, logService));
const wsServer = disposables.add(await WebSocketProtocolServer.create(options.port, logService));
// Side-effect handler — routes to the correct agent based on session URI
// Side-effect handler — delegates to AgentService for all orchestration
const sideEffects: IProtocolSideEffectHandler = {
handleAction(action: ISessionAction): void {
switch (action.type) {
case 'session/turnStarted': {
const agent = getAgent(action.session);
if (!agent) {
stateManager.dispatchServerAction({
type: 'session/error',
session: action.session,
turnId: action.turnId,
error: { errorType: 'noAgent', message: 'No agent found for session' },
});
return;
}
const attachments = action.userMessage.attachments?.map(a => ({
type: a.type,
path: a.path,
displayName: a.displayName,
}));
agent.sendMessage(action.session, action.userMessage.text, attachments).catch(err => {
logService.error('[AgentHostServer] sendMessage failed', err);
stateManager.dispatchServerAction({
type: 'session/error',
session: action.session,
turnId: action.turnId,
error: { errorType: 'sendFailed', message: String(err) },
});
});
break;
}
case 'session/permissionResolved': {
const agent = getAgent(action.session);
agent?.respondToPermissionRequest(action.requestId, action.approved);
break;
}
case 'session/turnCancelled': {
const agent = getAgent(action.session);
agent?.abortSession(action.session).catch(() => { });
break;
}
case 'session/modelChanged': {
const agent = getAgent(action.session);
agent?.changeModel?.(action.session, action.model).catch(err => {
logService.error('[AgentHostServer] changeModel failed', err);
});
break;
}
}
handleAction(action) {
agentService.dispatchAction(action, 'ws-server', 0);
},
async handleCreateSession(command: ICreateSessionParams): Promise<void> {
const provider = (command.provider ?? agents.keys().next().value) as AgentProvider;
const agent = agents.get(provider);
if (!agent) {
throw new Error(`No agent registered for provider: ${provider}`);
}
const session = await agent.createSession({
provider,
async handleCreateSession(command) {
await agentService.createSession({
provider: command.provider as AgentProvider | undefined,
model: command.model,
workingDirectory: command.workingDirectory,
});
const summary: ISessionSummary = {
resource: session,
provider,
title: 'Session',
},
handleDisposeSession(session) {
agentService.disposeSession(session);
},
async handleListSessions() {
const sessions = await agentService.listSessions();
return sessions.map(s => ({
resource: s.session,
provider: '' as AgentProvider,
title: s.summary ?? 'Session',
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
};
stateManager.createSession(summary);
stateManager.dispatchServerAction({ type: 'session/ready', session });
},
handleDisposeSession(session: URI): void {
const agent = getAgent(session);
agent?.disposeSession(session).catch(() => { });
stateManager.removeSession(session);
},
async handleListSessions(): Promise<ISessionSummary[]> {
const allSessions: ISessionSummary[] = [];
for (const agent of agents.values()) {
const sessions = await agent.listSessions();
const provider = agent.id;
for (const s of sessions) {
allSessions.push({
resource: s.session,
provider,
title: s.summary ?? 'Session',
status: SessionStatus.Idle,
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
});
}
}
return allSessions;
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
}));
},
};
// Wire up protocol handler
disposables.add(new ProtocolServerHandler(stateManager, wsServer, sideEffects, logService));
disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService));
// Report ready
const address = wsServer.address;

View File

@@ -36,6 +36,9 @@ export class AgentService extends Disposable implements IAgentService {
/** Authoritative state manager for the sessions process protocol. */
private readonly _stateManager: SessionStateManager;
/** Exposes the state manager for co-hosting a WebSocket protocol server. */
get stateManager(): SessionStateManager { return this._stateManager; }
/** Registered providers keyed by their {@link AgentProvider} id. */
private readonly _providers = new Map<AgentProvider, IAgent>();
/** Maps each active session URI (toString) to its owning provider. */
@@ -228,6 +231,13 @@ export class AgentService extends Disposable implements IAgentService {
}
break;
}
case 'session/modelChanged': {
const provider = this._findProviderForSession(action.session);
provider?.changeModel?.(action.session, action.model).catch(err => {
this._logService.error(`[AgentService] changeModel failed for session/modelChanged`, err);
});
break;
}
}
}

View File

@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from '../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { FileAccess, Schemas } from '../../../base/common/network.js';
import { Client, IIPCOptions } from '../../../base/parts/ipc/node/ipc.cp.js';
@@ -10,26 +11,77 @@ import { IEnvironmentService, INativeEnvironmentService } from '../../environmen
import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js';
import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js';
/**
* Options for configuring the agent host WebSocket server in the child process.
* When set, the agent host exposes a WebSocket endpoint for external clients.
*/
export interface IAgentHostWebSocketConfig {
/** TCP port to listen on. Mutually exclusive with `socketPath`. */
readonly port?: string;
/** Unix domain socket / named pipe path. Takes precedence over `port`. */
readonly socketPath?: string;
/** Host/IP to bind to. */
readonly host?: string;
/** Connection token value. When set, WebSocket clients must present this token. */
readonly connectionToken?: string;
}
/**
* Spawns the agent host as a Node child process (fallback when
* Electron utility process is unavailable, e.g. dev/test).
*/
export class NodeAgentHostStarter extends Disposable implements IAgentHostStarter {
private _wsConfig: IAgentHostWebSocketConfig | undefined;
private readonly _onRequestConnection = this._register(new Emitter<void>());
readonly onRequestConnection = this._onRequestConnection.event;
constructor(
@IEnvironmentService private readonly _environmentService: INativeEnvironmentService
) {
super();
}
/**
* Configures the child process to also start a WebSocket server.
* Must be called before {@link start}. Triggers eager process start
* via {@link onRequestConnection}.
*/
setWebSocketConfig(config: IAgentHostWebSocketConfig): void {
this._wsConfig = config;
// Signal the process manager to start immediately rather than
// waiting for a renderer window to connect.
this._onRequestConnection.fire();
}
start(): IAgentHostConnection {
const env: Record<string, string> = {
VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain',
VSCODE_PIPE_LOGGING: 'true',
VSCODE_VERBOSE_LOGGING: 'true',
};
// Forward WebSocket server configuration to the child process via env vars
if (this._wsConfig) {
if (this._wsConfig.port) {
env['VSCODE_AGENT_HOST_PORT'] = this._wsConfig.port;
}
if (this._wsConfig.socketPath) {
env['VSCODE_AGENT_HOST_SOCKET_PATH'] = this._wsConfig.socketPath;
}
if (this._wsConfig.host) {
env['VSCODE_AGENT_HOST_HOST'] = this._wsConfig.host;
}
if (this._wsConfig.connectionToken) {
env['VSCODE_AGENT_HOST_CONNECTION_TOKEN'] = this._wsConfig.connectionToken;
}
}
const opts: IIPCOptions = {
serverName: 'Agent Host',
args: ['--type=agentHost', '--logsPath', this._environmentService.logsHome.with({ scheme: Schemas.file }).fsPath],
env: {
VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain',
VSCODE_PIPE_LOGGING: 'true',
VSCODE_VERBOSE_LOGGING: 'true',
}
env,
};
const agentHostDebug = parseAgentHostDebugPort(this._environmentService.args, this._environmentService.isBuilt);

View File

@@ -6,14 +6,32 @@
// WebSocket transport for the sessions process protocol.
// Uses JSON serialization with URI revival for cross-process communication.
import { WebSocketServer, WebSocket } from 'ws';
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { connectionTokenQueryName } from '../../../base/common/network.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import type { IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js';
/**
* Options for creating a {@link WebSocketProtocolServer}.
* Provide either `port`+`host` or `socketPath`, not both.
*/
export interface IWebSocketServerOptions {
/** TCP port to listen on. Ignored when {@link socketPath} is set. */
readonly port?: number;
/** Host/IP to bind to. Defaults to `'127.0.0.1'`. */
readonly host?: string;
/** Unix domain socket / Windows named pipe path. Takes precedence over port. */
readonly socketPath?: string;
/**
* Optional token validator. When provided, WebSocket upgrade requests
* must include a valid token in the `tkn` query parameter.
*/
readonly connectionTokenValidate?: (token: unknown) => boolean;
}
// ---- JSON serialization helpers ---------------------------------------------
function uriReplacer(_key: string, value: unknown): unknown {
@@ -53,7 +71,10 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT
private readonly _onClose = this._register(new Emitter<void>());
readonly onClose = this._onClose.event;
constructor(private readonly _ws: WebSocket) {
constructor(
private readonly _ws: import('ws').WebSocket,
private readonly _WebSocket: typeof import('ws').WebSocket,
) {
super();
this._ws.on('message', (data: Buffer | string) => {
@@ -77,7 +98,7 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT
}
send(message: IProtocolMessage): void {
if (this._ws.readyState === WebSocket.OPEN) {
if (this._ws.readyState === this._WebSocket.OPEN) {
this._ws.send(JSON.stringify(message, uriReplacer));
}
}
@@ -93,10 +114,15 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT
/**
* WebSocket server that accepts client connections and wraps each one
* as an {@link IProtocolTransport}.
*
* Use the static {@link create} method to construct — it dynamically imports
* `ws` and `http`/`url` so the modules are only loaded when needed.
*/
export class WebSocketProtocolServer extends Disposable implements IProtocolServer {
private readonly _wss: WebSocketServer;
private readonly _wss: import('ws').WebSocketServer;
private readonly _httpServer: import('http').Server | undefined;
private readonly _WebSocket: typeof import('ws').WebSocket;
private readonly _onConnection = this._register(new Emitter<IProtocolTransport>());
readonly onConnection = this._onConnection.event;
@@ -109,17 +135,66 @@ export class WebSocketProtocolServer extends Disposable implements IProtocolServ
return `${addr.address}:${addr.port}`;
}
constructor(
private readonly _port: number,
@ILogService private readonly _logService: ILogService,
/**
* Creates a new WebSocket protocol server. Dynamically imports `ws`,
* `http`, and `url` so callers don't pay the cost when unused.
*/
static async create(
options: IWebSocketServerOptions | number,
logService: ILogService,
): Promise<WebSocketProtocolServer> {
const [ws, http, url] = await Promise.all([
import('ws'),
import('http'),
import('url'),
]);
return new WebSocketProtocolServer(options, logService, ws, http, url);
}
private constructor(
options: IWebSocketServerOptions | number,
private readonly _logService: ILogService,
ws: typeof import('ws'),
http: typeof import('http'),
url: typeof import('url'),
) {
super();
this._wss = new WebSocketServer({ port: this._port, host: '127.0.0.1' });
this._logService.info(`[WebSocketProtocol] Server listening on 127.0.0.1:${this._port}`);
this._wss.on('connection', (ws) => {
this._WebSocket = ws.WebSocket;
// Backwards compat: accept a plain port number
const opts: IWebSocketServerOptions = typeof options === 'number' ? { port: options } : options;
const host = opts.host ?? '127.0.0.1';
const verifyClient = opts.connectionTokenValidate
? (info: { req: import('http').IncomingMessage }, cb: (res: boolean, code?: number, message?: string) => void) => {
const parsedUrl = url.parse(info.req.url ?? '', true);
const token = parsedUrl.query[connectionTokenQueryName];
if (!opts.connectionTokenValidate!(token)) {
this._logService.warn('[WebSocketProtocol] Connection rejected: invalid connection token');
cb(false, 403, 'Forbidden');
return;
}
cb(true);
}
: undefined;
if (opts.socketPath) {
// For socket paths, create an HTTP server listening on the path
// and attach the WebSocket server to it.
this._httpServer = http.createServer();
this._wss = new ws.WebSocketServer({ server: this._httpServer, verifyClient });
this._httpServer.listen(opts.socketPath, () => {
this._logService.info(`[WebSocketProtocol] Server listening on socket ${opts.socketPath}`);
});
} else {
this._wss = new ws.WebSocketServer({ port: opts.port, host, verifyClient });
this._logService.info(`[WebSocketProtocol] Server listening on ${host}:${opts.port}`);
}
this._wss.on('connection', (wsConn) => {
this._logService.trace('[WebSocketProtocol] New client connection');
const transport = new WebSocketProtocolTransport(ws);
const transport = new WebSocketProtocolTransport(wsConn, this._WebSocket);
this._onConnection.fire(transport);
});
@@ -130,6 +205,7 @@ export class WebSocketProtocolServer extends Disposable implements IProtocolServ
override dispose(): void {
this._wss.close();
this._httpServer?.close();
super.dispose();
}
}

View File

@@ -834,6 +834,7 @@ export async function createServer(address: string | net.AddressInfo | null, arg
output += `\n`;
console.log(output);
}
return remoteExtensionHostAgentServer;
}

View File

@@ -85,6 +85,9 @@ export const serverOptions: OptionDescriptions<Required<ServerParsedArgs>> = {
'remote-auto-shutdown-without-delay': { type: 'boolean' },
'inspect-ptyhost': { type: 'string', allowEmptyValue: true },
'agent-host-port': { type: 'string', cat: 'o', args: 'port', description: nls.localize('agent-host-port', "The port the agent host WebSocket server should listen to.") },
'agent-host-path': { type: 'string', cat: 'o', args: 'path', description: nls.localize('agent-host-path', "The path to a socket file for the agent host WebSocket server to listen to.") },
'use-host-proxy': { type: 'boolean' },
'without-browser-env-var': { type: 'boolean' },
'reconnection-grace-time': { type: 'string', cat: 'o', args: 'seconds', description: nls.localize('reconnection-grace-time', "Override the reconnection grace time window in seconds. Defaults to 10800 (3 hours).") },
@@ -215,6 +218,9 @@ export interface ServerParsedArgs {
'remote-auto-shutdown-without-delay'?: boolean;
'inspect-ptyhost'?: string;
'agent-host-port'?: string;
'agent-host-path'?: string;
'use-host-proxy'?: boolean;
'without-browser-env-var'?: boolean;
'reconnection-grace-time'?: string;

View File

@@ -56,7 +56,7 @@ import { ServerTelemetryChannel } from '../../platform/telemetry/common/remoteTe
import { IServerTelemetryService, ServerNullTelemetryService, ServerTelemetryService } from '../../platform/telemetry/common/serverTelemetryService.js';
import { RemoteTerminalChannel } from './remoteTerminalChannel.js';
import { createURITransformer } from '../../base/common/uriTransformer.js';
import { ServerConnectionToken } from './serverConnectionToken.js';
import { ServerConnectionToken, ServerConnectionTokenType } from './serverConnectionToken.js';
import { ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js';
import { REMOTE_TERMINAL_CHANNEL_NAME } from '../../workbench/contrib/terminal/common/remote/remoteTerminalChannel.js';
import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js';
@@ -232,6 +232,14 @@ export async function setupServerServices(connectionToken: ServerConnectionToken
const agentHostStarter = instantiationService.createInstance(NodeAgentHostStarter);
disposables.add(instantiationService.createInstance(AgentHostProcessManager, agentHostStarter));
if (args['agent-host-port'] || args['agent-host-path']) {
agentHostStarter.setWebSocketConfig({
port: args['agent-host-port'],
socketPath: args['agent-host-path'],
host: args.host || 'localhost',
connectionToken: connectionToken.type === ServerConnectionTokenType.Mandatory ? connectionToken.value : undefined,
});
}
services.set(IAllowedMcpServersService, new SyncDescriptor(AllowedMcpServersService));
services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService));