Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

08 — Extension host + client

Article 07 built the server side of the LSP wire: an LspServerEmitter writing ${outDir}/server/src/server.ts, a createConnection() over IPC, a TextDocuments manager, and a capability bag derived from IR.lspFeatures. It stopped at the point where the server is ready to answer initialize — but a server without a client is a process waiting on stdin that never receives a request. This article writes the client: the extension-host entry point that VSCode loads into its renderer-side extension host, boots the language client from, registers commands on, and suspends when the user closes the last file of the language.

The extension-host emitter runs late in the registry — after grammar, snippets, executors, lsp-server, and before the manifest emitter of article 05. Its output — ${outDir}/src/extension.ts — is the file VSCode runs first on activation, and the file that holds the handle to every runtime concern outliving a single request: the LanguageClient, the command disposables, the task provider registration. If article 07's server handles textDocument/completion, this emitter is the process that decides the server exists.

The design-series companion is 03 — The trip and the guardrails, which walked through the edit-reload cycle experientially. This article writes the four lines of that trip the generator actually emits — the activate() body, the LanguageClient constructor, the command loop, deactivate() — and derives the activation events that put VSCode on the hook to call them at the right moment.

REQ-IDEDSL-EXTENSION-HOST-LAZY-ACTIVATION — the Requirement

REQ-IDEDSL-EXTENSION-HOST-LAZY-ACTIVATIONThe extension-host emitter shall emit ${outDir}/src/extension.ts such that: (a) the emitted module exports an activate(context: vscode.ExtensionContext): Promise<void> function and a deactivate(): Promise<void> function, and no other top-level side-effecting code; (b) activate constructs exactly one LanguageClient instance with a ServerOptions pointing at ./server/src/server.js under context.extensionPath using the node runtime and the TransportKind.ipc transport, and with a ClientOptions.documentSelector of [{ scheme: 'file', language: <languageId> }]; (c) for every IRExecutor in IR.executors, activate registers one vscode.commands.registerCommand whose command id is ${config.extensionId}.${executor.id} and pushes the disposable into context.subscriptions; (d) deactivate calls client.stop() and awaits its return; (e) the manifest fragment the extension-host emitter contributes includes an activationEvents array that is the deterministic union of onLanguage:<languageId> (one entry per distinct fileExtension-to-languageId binding), onCommand:${config.extensionId}.${executor.id} (one per IRExecutor), and optionally onTaskType:ide-dsl-<languageId> when the task provider from article 06 is registered, and MUST NOT contain onStartupFinished or *.

Rationale: the extension host is the one piece of the emitted extension with the power to wake every other piece — it is what VSCode calls when an activation event fires, and its activate() body is the critical path for the user's perceived latency from opening a file to seeing completions. Keeping this file thin (three responsibilities: register commands, boot the client, store disposables) is what keeps the latency argument from degrading over time as more features accrete; keeping the activation events derived from the IR rather than author-written is what prevents the extension from doing the one thing VSCode extensions are most often criticised for, namely waking on every workspace load regardless of whether the user actually touches the language. The VSCode 1.75 deprecation of * as an activation event made eager activation an explicit anti-pattern in the API surface; the generator makes it an explicit anti-pattern in the emitted code by simply not emitting the string.

Fit criteria: given an IR with id: 'example', fileExtensions: ['.ex'], executors: [{ id: 'compliance-strict', command: 'compliance --strict', title: 'Compliance — strict' }], and IdeForgeConfig with publisher: 'frenchexdev', extensionId: 'ide-dsl-example', the build shall produce (i) a src/extension.ts file that type-checks against @types/vscode and vscode-languageclient; (ii) whose activate() registers exactly one command with id ide-dsl-example.compliance-strict; (iii) whose LanguageClient documentSelector contains { scheme: 'file', language: 'example' }; (iv) a manifest fragment whose activationEvents array equals ['onLanguage:example', 'onCommand:ide-dsl-example.compliance-strict', 'onTaskType:ide-dsl-example'] in that exact order; (v) no occurrence of the strings onStartupFinished or "*" in the emitted manifest fragment.

Verification: Test. Refines REQ-IDEDSL-IR-VERSIONED-CONTRACT; relates to REQ-IDEDSL-MANIFEST-DERIVATION-COMPLETE.

Three notes before the article unfolds. First, lazy activation is not a micro-optimisation: a workspace with fifty Ide.Dsl-generated extensions all declaring onStartupFinished pays fifty extension-host cold-starts before the user can type. The per-extension cost is small but non-zero; the compounding cost is the problem. A generator can only be a good citizen of the ecosystem by refusing to emit the anti-pattern.

Second, the command id — ${extensionId}.${executor.id} — must match exactly the one registered through vscode.commands.registerCommand and the one stored in contributes.commands by article 06. VSCode command ids are a separate namespace from the marketplace id ${publisher}.${extensionId}: the publisher belongs to the install-time identity of the extension, not to the runtime command dispatcher. Both emitters read the same IdeForgeConfig.extensionId and the same IRExecutor[], so they agree by construction. Drift would present as a palette command that does nothing when picked — preventable by making both emitters read from one source.

Third, the onTaskType:ide-dsl-<languageId> event is conditional: the task provider is only emitted when at least one IRExecutor exists. An empty-executor spec.ts reduces activation events to the single onLanguage: entry. No dead events.

FEAT-IDEDSL-08 — the satisfying Feature

// packages/ide-dsl/requirements/features/extension-host.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdeDslExtensionHostLazyActivationRequirement } from '../requirements/req-idedsl-extension-host-lazy-activation.js';

@Satisfies(ReqIdeDslExtensionHostLazyActivationRequirement)
export abstract class ExtensionHostFeature extends Feature {
  readonly id = 'FEAT-IDEDSL-08';
  readonly title = 'ExtensionHostEmitter projects IR and IdeForgeConfig into a thin activate/deactivate scaffold with a booted LanguageClient';
  readonly priority = Priority.Critical;

  // ── Host scaffold ──
  abstract extensionTsExportsActivateAndDeactivateOnly(): ACResult;
  abstract activateIsAsyncAndAwaitsClientStart(): ACResult;
  abstract deactivateAwaitsClientStop(): ACResult;
  abstract disposablesArePushedIntoContextSubscriptions(): ACResult;

  // ── Client boot ──
  abstract languageClientConstructorPointsAtEmittedServerModule(): ACResult;
  abstract serverOptionsUseNodeRuntimeAndIpcTransport(): ACResult;
  abstract clientOptionsDocumentSelectorMatchesLanguageId(): ACResult;

  // ── Activation events derivation ──
  abstract activationEventsAreDeterministicUnionOfIrAndExecutors(): ACResult;
  abstract activationEventsNeverContainOnStartupFinishedOrStar(): ACResult;
  abstract onTaskTypeActivationEventIsConditionalOnExecutorPresence(): ACResult;
  abstract commandIdRegistrationMatchesManifestCommandIdExactly(): ACResult;
}

Eleven ACs in three clusters. The Host scaffold cluster pins the shape of extension.ts — two exports, async, disposables routed correctly. The Client boot cluster nails the three constructor arguments to LanguageClient that future refactorings are most likely to touch. The Activation events derivation cluster is the one that earns the Requirement's headline claim: lazy activation, no eager events, and a consistency check that the command id in activationEvents is the same string registered at runtime.

The emitted extension.ts

The file is short — 60 lines, roughly, most of them imports and a command loop. Here it is:

// ${outDir}/src/extension.ts — emitted by ExtensionHostEmitter
import * as path from 'node:path';
import * as vscode from 'vscode';
import {
  LanguageClient,
  type LanguageClientOptions,
  type ServerOptions,
  TransportKind,
} from 'vscode-languageclient/node';
import { TaskProvider } from './task-provider.js';

let client: LanguageClient | undefined;

export async function activate(context: vscode.ExtensionContext): Promise<void> {
  const serverModule = context.asAbsolutePath(
    path.join('server', 'src', 'server.js'),
  );

  const serverOptions: ServerOptions = {
    run:   { module: serverModule, transport: TransportKind.ipc },
    debug: { module: serverModule, transport: TransportKind.ipc,
             options: { execArgv: ['--nolazy', '--inspect=6009'] } },
  };

  const clientOptions: LanguageClientOptions = {
    documentSelector: [{ scheme: 'file', language: 'example' }],
    initializationOptions: {
      irVersion: 1,
      languageId: 'example',
    },
  };

  client = new LanguageClient(
    'ide-dsl-example',
    'Ide.Dsl — example',
    serverOptions,
    clientOptions,
  );

  const executors = [
    { id: 'compliance-strict', command: 'compliance --strict', title: 'Compliance — strict' },
  ] as const;

  for (const executor of executors) {
    const commandId = `ide-dsl-example.${executor.id}`;
    context.subscriptions.push(
      vscode.commands.registerCommand(commandId, () => runExecutor(executor)),
    );
  }

  context.subscriptions.push(
    vscode.tasks.registerTaskProvider('ide-dsl-example', new TaskProvider()),
  );

  await client.start();
}

export async function deactivate(): Promise<void> {
  if (client !== undefined) {
    await client.stop();
  }
}

function runExecutor(executor: { command: string; title: string }): void {
  const terminal = vscode.window.createTerminal(executor.title);
  terminal.sendText(executor.command);
  terminal.show();
}

Four things distinguish this file from a hand-written VSCode extension. The serverModule path is a context.asAbsolutePath call pointed at article 07's artefact; if article 07 changes its output layout, this emitter changes in lockstep, and that coupling lives in a single path.join call the test suite can assert. The executors array is inlined from IR.executors at emit time — no dynamic discovery. The command id carries the two-segment ${extensionId}.${executorId} form matching what the executor emitter wrote into contributes.commands. And initializationOptions carries IR version and language id into the server's initialize handler, which is how the server (article 07) recovers its type at boot without importing the IR from a sibling package.

runExecutor is deliberately primitive — opens a terminal, types the command. A richer version could use vscode.tasks.executeTask() through the provider, but the task provider is already registered on the line above, and delegating command invocation to the task runner would introduce a cycle between command registration and task resolution. A future article may revisit.

The LanguageClient boot

The LanguageClient constructor takes four arguments: an id (used for output-channel naming and log prefixing), a display name (the label shown in the command palette), a ServerOptions, and a ClientOptions. The microsoft/vscode-languageserver-node repository — upstream for both vscode-languageclient used here and vscode-languageserver used in article 07 — has carried this four-argument shape since version 5.0 (August 2018). The constructor returns a LanguageClient exposing start() (resolves after the server answers initialize and the client sends initialized), stop() (sends shutdown then exit), and sendRequest/onNotification for out-of-band traffic.

ServerOptions in its NodeModule form specifies the Node.js module holding the server's createConnection() call, the transport, and optional runtime arguments. The run branch ships to end users; the debug branch activates under the Extension Development Host, and its execArgv carries --nolazy --inspect=6009 so a language author can attach a Node inspector at startup. Both branches point at server/src/server.js — the transpiled output of article 07 — and both use TransportKind.ipc, which wires process.send() / process.on('message') between parent and child. IPC is the cheapest transport with the tightest framing; the VSCode activation events documentation does not constrain transport choice, but the LSP examples in vscode-languageserver-node have used IPC as canonical since the 4.x era.

ClientOptions.documentSelector filters which documents the client forwards. Its scheme: 'file' keeps the client from chasing virtual documents (the git: scheme, vscode-userdata:, output-channel schemes) that would confuse a server expecting on-disk files; its language field matches the languageId registered by article 04's grammar emitter in contributes.languages. If the two drift, the client stays silent when the user opens a .ex file — presenting as "server running but no completions fire", a bug class caught by the integration test below.

Command registrations

The command loop is plain: iterate over IR.executors, derive the command id, register, push the disposable. Three details earn comment.

First, the command id is a constant string at registration time, not a function of runtime state. VSCode's registerCommand throws synchronously if the id is already registered; registering at activate() time and pushing to context.subscriptions ensures that the symmetric unregistration happens when VSCode calls context.subscriptions.forEach(d => d.dispose()) during extension deactivation. If the generator ever emitted a lazy command registration — inside a setTimeout or a conditional — the symmetry would break and a deactivate-reactivate cycle would start throwing "command already registered" errors. The simple for-loop at the top of activate avoids the whole category.

Second, the handler — () => runExecutor(executor) — is a closure over the loop variable. The for…of construct binds a fresh executor per iteration (unlike forEach which shares in some older transpilation targets), so the closure captures the right one. This is a TypeScript micro-point that matters because the executor emitter of article 06 emits executors as a readonly tuple, and readonly arrays work identically with for…of; no special care is needed.

Third, the task provider from article 06 is registered alongside the commands in the same activate body. The TaskProvider class is imported from ./task-provider.js — which is the file the executor emitter of article 06 generated as a sibling of this one in the same ${outDir}/src/ directory. The extension-host emitter does not re-generate the task provider; it merely imports the class and registers it. That separation is the cleanest expression of the SRP claim that each emitter owns exactly one output kind — and the cleanest demonstration that the registry's ordering matters: the executor emitter must run before the extension-host emitter, otherwise ./task-provider.js is not on disk when the manifest ships and the vscode.tasks.registerTaskProvider call at activation time throws a module-not-found.

Activation events derivation

The derivation algorithm is seven lines of pseudocode, nine of TypeScript, and the crux of the Requirement's lazy-activation invariant. Here it is in prose:

For every distinct languageId in the IR (every Ide.Dsl spec has one, but a future extension could ship multiple — the derivation algorithm is written against the general case), emit one onLanguage:<languageId> event. This is the event VSCode fires when the user opens, reveals, or creates a document whose languageId matches, which is exactly the moment the client should be awake and forwarding notifications.

For every IRExecutor in IR.executors, emit one onCommand:<extensionId>.<executor.id> event. This is the event VSCode fires when the command is invoked through the palette, through a keybinding, or through vscode.commands.executeCommand. Without this event, the user could open the palette, see the command (because contributes.commands is declarative and static), pick it, and have nothing happen — the extension would still be sleeping and the command handler would never have been registered. Adding one activation event per command is the canonical fix.

If the extension includes a task provider — which is equivalent to "if IR.executors.length > 0" because article 06's executor emitter conditionally produces the task provider on that same condition — emit one onTaskType:ide-dsl-<languageId> event. This is the event VSCode fires when the user opens the "Run Task" picker and the picker polls task providers for contributions. Without this event, the extension sleeps through the task picker's poll and the user sees an empty task list from the extension; VSCode does not re-poll on activation, so the event is strictly necessary to thread the task provider into the picker at the right instant.

Never emit onStartupFinished. This event fires shortly after the workbench finishes loading, which is equivalent to eager activation with a short delay; VSCode's extension-authoring guidelines have discouraged it since the event was introduced, and the generator's lazy-activation invariant forbids it outright. Never emit * — this has been a VSCode deprecation since release 1.75 (January 2023), and current releases log a warning at install time. The emitter treats both strings as errors if a future contributor ever adds them by accident; the test suite asserts their absence in the output.

The derivation is pure: given the same IR and config, the emitter produces the same activation events array. Order is stable — language events first, command events next (sorted by executor id), task-type event last — which lets the test suite compare the emitted array to a hand-written expected array by deep equality rather than set equality. Determinism here is a small kindness to the diff viewer; the reader-facing reward is that two spec.ts files that differ by a trailing newline produce byte-identical package.json manifests.

Mermaid 1 — host boot sequence

Diagram
Figure 1 — Host boot sequence for an Ide.Dsl-generated extension, from activation event to LSP initialize/initialized handshake.

The sequence makes two design claims visible at a glance. The first is that the extension host runs in a process separate from the server — there are five participants above the IPC boundary and one below it, and the only arrow that crosses the boundary is fork. The second is that client.start() is the single synchronisation point between the two processes: until it resolves, activate() does not return, and until activate() returns, VSCode does not consider the extension activated. That is what bounds the user-perceived latency of "open file → see completions" to the time between the onLanguage match and the server's initialized acknowledgement.

Mermaid 2 — the two-process boundary

Diagram
Figure 2 — The two processes the generator emits into, the IPC channel that connects them, and the shared runtime code that crosses no boundary.

The @frenchexdev/ide-runtime shared package — the one that holds the LanguageId branded type, the IRVersion constant, and any utility types both sides need — is bundled into the host's build and into the server's build independently. Neither side imports the other at TypeScript level; they both depend on the shared runtime, and the IPC channel carries JSON over process.send, which is why the initializationOptions bag carries the IR version and language id as plain values rather than object references. This is the concrete answer to "why is the IR not just imported by the server" — because the server runs in a process the host forked, and process-forking does not share module graphs, so the only reliable way to pass shape information across is as serialisable data in initialize's parameter bag.

Test snippet

// packages/ide-dsl/test/unit/emitters/extension-host-emitter.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { ExtensionHostEmitter } from '../../../src/emitters/extension-host-emitter.js';
import { buildIR, buildConfig, inMemoryFs } from '../../fixtures/factories.js';

class ExtensionHostEmitterTests {
  @FeatureTest('FEAT-IDEDSL-08')
  @Verifies('activationEventsAreDeterministicUnionOfIrAndExecutors')
  async activationEventsAreUnion() {
    const ir = buildIR({
      id: 'example',
      fileExtensions: ['.ex'],
      executors: [
        { id: 'compliance-strict', command: 'compliance --strict', title: 'Compliance — strict' },
        { id: 'trace-render', command: 'trace render', title: 'Trace — render' },
      ],
    });
    const config = buildConfig({ publisher: 'frenchexdev', extensionId: 'ide-dsl-example' });
    const fs = inMemoryFs();
    const emitter = new ExtensionHostEmitter(config);

    const fragment = await emitter.emit(ir, fs, '/out');

    expect(fragment.activationEvents).toEqual([
      'onLanguage:example',
      'onCommand:ide-dsl-example.compliance-strict',
      'onCommand:ide-dsl-example.trace-render',
      'onTaskType:ide-dsl-example',
    ]);
  }

  @FeatureTest('FEAT-IDEDSL-08')
  @Verifies('activationEventsNeverContainOnStartupFinishedOrStar')
  async noEagerActivation() {
    const ir = buildIR({ id: 'example', fileExtensions: ['.ex'], executors: [] });
    const config = buildConfig();
    const fs = inMemoryFs();
    const emitter = new ExtensionHostEmitter(config);

    const fragment = await emitter.emit(ir, fs, '/out');

    expect(fragment.activationEvents).not.toContain('onStartupFinished');
    expect(fragment.activationEvents).not.toContain('*');
    expect(fragment.activationEvents).toEqual(['onLanguage:example']);
  }

  @FeatureTest('FEAT-IDEDSL-08')
  @Verifies('commandIdRegistrationMatchesManifestCommandIdExactly')
  async commandIdConsistency() {
    const ir = buildIR({
      id: 'example',
      fileExtensions: ['.ex'],
      executors: [{ id: 'x', command: 'x', title: 'X' }],
    });
    const config = buildConfig({ publisher: 'frenchexdev', extensionId: 'ide-dsl-example' });
    const fs = inMemoryFs();
    const emitter = new ExtensionHostEmitter(config);

    await emitter.emit(ir, fs, '/out');

    const source = await fs.readFile('/out/src/extension.ts', 'utf8');
    expect(source).toContain("'ide-dsl-example.x'");
    // the same literal must appear in the activationEvents entry
    const fragment = await emitter.emit(ir, fs, '/out');
    expect(fragment.activationEvents).toContain('onCommand:ide-dsl-example.x');
  }

  @FeatureTest('FEAT-IDEDSL-08')
  @Verifies('onTaskTypeActivationEventIsConditionalOnExecutorPresence')
  async taskTypeConditional() {
    const irWithoutExecutors = buildIR({ id: 'example', fileExtensions: ['.ex'], executors: [] });
    const emitter = new ExtensionHostEmitter(buildConfig());
    const fragment = await emitter.emit(irWithoutExecutors, inMemoryFs(), '/out');

    expect(fragment.activationEvents.some(e => e.startsWith('onTaskType:'))).toBe(false);
  }
}

Four tests covering the four most consequential ACs. The determinism test pins the exact array order, so a change to the derivation algorithm is visible in the diff. The no-eager-activation test pins the two forbidden strings, so a regression that re-introduces onStartupFinished fails the suite. The command-id consistency test reads the generated source and the fragment's activation events and asserts the same string appears in both — this is the test that catches drift between this emitter and article 06's executor emitter. The conditional task-type test confirms that an IR with zero executors produces zero task-related activation events, no dead entries.

The SOLID lens

Single-responsibility splits cleanly across the process boundary. The extension host is responsible for booting — it builds options, it constructs the client, it registers commands, it starts the client. It does not handle any LSP request; every request the user triggers is forwarded to the server process, and the host is not on the call stack when, say, a completion request resolves. The server is responsible for handling — it receives requests, it reads documents from its TextDocuments manager, it returns capabilities-derived responses. Neither module reaches into the other's responsibility; the IPC boundary enforces the split at the process level in a way no code convention could.

Interface segregation is visible in the activate(context: vscode.ExtensionContext) signature. The extension-host emitter writes code that depends on exactly three members of vscode.ExtensionContext: extensionPath, asAbsolutePath, and subscriptions. It does not import vscode.Uri, vscode.workspace, vscode.window at top-level; the vscode.window.createTerminal call in runExecutor is the only direct vscode.window reach, and it is quarantined behind a single function. If a future refactoring moved the terminal dispatch into a separate CommandDispatcher class, the extension-host file would depend on nothing from vscode beyond ExtensionContext and commands.registerCommand — a much narrower surface than a typical VSCode extension file imports.

Dependency inversion is what the IPC channel buys. The host is a client of the server's LSP contract — initialize, textDocument/completion, textDocument/hover — and knows nothing about the server's implementation language, its module graph, its document-manager choice. The server could be rewritten in Rust and compiled to a Node addon; the host would change one line in ServerOptions (swap TransportKind.ipc for a custom transport) and otherwise be untouched. That is the LSP's original design pitch — protocol over implementation — and the extension-host emitter puts the pitch in practice by depending on the protocol's type surface (LanguageClient, ServerOptions, ClientOptions) and nothing below it.

The DRY lens

The activation events array is the DRY-lens centrepiece of this article. It is derived from the IR (fileExtensions, executors) and the config (extensionId) — two to three strings of input per activation event — and the derivation runs exactly once per build, in this emitter. Neither the language author writes activation events by hand, nor does any other emitter re-derive them. The manifest emitter of article 05 receives the pre-derived array as a fragment and splices it into package.json; article 06's executor emitter does not know about activation events at all.

The command id is similarly derived once per executor, once per place it appears. Article 06 writes it into contributes.commands.command. Article 08 writes it into vscode.commands.registerCommand(id, handler) and into the onCommand: activation event. All three appearances are produced by the same derivation — ${extensionId}.${executor.id} — with extensionId from IdeForgeConfig and executor.id from the IR. The publisher deliberately does not appear in the command id: it belongs to the marketplace identity ${publisher}.${extensionId}, not to the runtime command dispatcher. Conflating the two namespaces was the original draft's mistake; keeping them separate is the convention every first-party VSCode extension follows.

The pattern behind both derivations is the same: single source (IR + config), one derivation function, multiple call sites, zero author-facing configuration. The author writes the @Executor decorator; the generator does everything else. That is the whole DRY argument of 05 — DRY and the IR as contract in one concrete emitter.

Cross-link to design article

The design-series companion 03 — The trip and the guardrails described the edit-reload trip at the level of what the user perceives: edit, save, reload window, open file, see completions. This article wrote the four lines of that trip that are actually code the generator emits — activate()'s body, the LanguageClient constructor call, the command loop, deactivate() — and showed how the activation events derivation keeps the trip from waking the extension before the user has asked for it. Read the design article for the experiential account of the trip; read this article for the code that makes the trip finish.

Proposal — in-process versus out-of-process server

Proposal (write-in-public). The current ServerOptions emits TransportKind.ipc, which runs the server in a forked child process. That buys memory isolation — a crashing server takes itself out without taking the extension host with it — and buys debuggability — the --inspect=6009 flag attaches a separate Node inspector to the server. It costs cold-start time: forking a Node process and replaying its module graph on every activation is a 100–300 ms overhead on a warm machine, more on Windows. VSCode's vscode-languageclient has supported an in-process mode since version 6 (via worker_threads-backed transports), which would cut the cold-start to sub-50 ms at the cost of blending the server's memory footprint into the host's and losing the process-isolation guarantee. The trade-off is real; the choice depends on whether a given Ide.Dsl extension is more likely to crash (favour isolation) or more likely to be opened many times a day on short tasks (favour speed). The generator currently picks isolation by default. An IdeForgeConfig.serverTransport: 'ipc' | 'in-process' field would let the language author override, but that is one more knob on the config surface the next article — or a future refactoring — may or may not want to turn into a first-class choice.

Where article 09 goes

Article 09 is the manifest emitter's final pass: it merges the fragments contributed by articles 11 through 15 into a single package.json and closes the pipeline. After that the build output is a complete extension directory that vsce package turns into a .vsix.

The Theia project — API-compatible with VSCode extensions since 2017 — is the standing demonstration that the activation-events contract and the LanguageClient boot sequence described here are not VSCode-specific. Theia reads the same activationEvents array, fires the same onLanguage: events, and expects the same activate() export; an Ide.Dsl-generated extension runs on Theia unchanged. Cross-host portability is the quiet payoff of writing lazy activation as a Requirement rather than an optimisation.

⬇ Download