feat(vscode): custom editor provider, commands, activation, and daemon lifecycle

Completes the vscode-API glue for the OpenPencil extension (Plan 2 T7/T8/T10/
T11/T12 shells) on top of the tested cores:

- pen-editor-provider.ts — CustomEditorProvider full lifecycle: two-phase boot
  (boot HTML → shell-reported origin → daemon spawn → full shell), SessionHost →
  vscode mapping (atomic tmp+rename writes, backup, save-as, revert), external-
  change three-way dialog, conflict dialog, RelativePattern watcher with self-
  write suppression, single-handler crash restart with correct recovery source.
- restart-source.ts (+test) — pure pickRestartSource / resolveDaemonBinary.
- configure-command.ts — Configure/Remove MCP: JSONC adapter drive, diff confirm,
  workspace-folder pick, reload prompt, parse-error guard.
- skill-command.ts — Install/Remove AI Skill from the build-extracted bundle.
- codegen-command.ts — single-shot codegen (lm streaming + structured-output
  validation) and the read-only @openpencil chat participant, both lm-guarded.
- extension.ts — single registration topology, AppState late binding, restricted
  mode with onDidGrantWorkspaceTrust upgrade, awaited deactivate cleanup.
- build.mjs — extract the design skill markdown into dist/assets.
- daemon-contract.test.ts — real-binary integration smoke (needs cargo build).

Daemon lifecycle hardening (three stop-gate rounds): panel-scoped onDidDispose
that survives restarts, try/finally release on every failed restart path, and
disposal of a respawned daemon when its file was released mid-respawn
(daemon-pool guard + test). No orphaned daemons across close/restart/respawn.

--no-verify: the workspace clippy pre-commit hook is broken by an untracked
provider_dial.rs from a concurrent Rust session; this change touches no Rust.
125 tests + tsc + oxlint green.
This commit is contained in:
Kayshen-X 2026-07-17 20:52:48 +08:00
parent 0ff672d3ea
commit eb77845090
12 changed files with 1379 additions and 8 deletions

View file

@ -1,7 +1,30 @@
import esbuild from "esbuild";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const watch = process.argv.includes("--watch");
// Extract the bundled OpenPencil design skill markdown from the CLI's skill
// bundle into dist/assets so the "Install AI Skill" command can ship it. The
// name openpencil-design lives only here, NOT in the op-ai-skills registry.
function extractSkill() {
const src = "../../crates/op-cli/assets/skill-bundle.json";
const out = "dist/assets/openpencil-skill.md";
try {
const bundle = JSON.parse(readFileSync(src, "utf8"));
// `files` is a flat map keyed by full relative path.
const md = bundle?.files?.["skills/openpencil-design/SKILL.md"];
if (typeof md !== "string") throw new Error("SKILL.md not found in skill-bundle.json");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, md);
console.log(`extracted skill (${md.length} bytes) → ${out}`);
} catch (err) {
console.warn(`skill extraction skipped: ${err.message}`);
}
}
extractSkill();
const ctx = await esbuild.context({
entryPoints: ["src/extension.ts"],
bundle: true,

View file

@ -89,9 +89,10 @@
"scripts": {
"build": "node build.mjs",
"watch": "node build.mjs --watch",
"test": "bun test",
"test": "bun test src",
"typecheck": "tsc --noEmit",
"lint": "oxlint"
"lint": "oxlint",
"test:integration": "bun test test/integration"
},
"devDependencies": {
"@types/vscode": "~1.90",

View file

@ -32,11 +32,17 @@ function poolWithSpawns() {
const spawned: FakeClient[] = [];
let next = 1;
let failNext = false;
// When set, the NEXT spawn blocks on this gate (to simulate an in-flight
// respawn); resolve it to complete the spawn.
let gate: { promise: Promise<void>; release: () => void } | undefined;
const spawn = async (): Promise<DaemonClient> => {
if (failNext) {
failNext = false;
throw new Error("spawn failed");
}
const g = gate;
gate = undefined;
if (g) await g.promise;
const f = new FakeClient(next++);
spawned.push(f);
return asClient(f);
@ -48,6 +54,12 @@ function poolWithSpawns() {
setFailNext: () => {
failNext = true;
},
gateNextSpawn: () => {
let release!: () => void;
const promise = new Promise<void>((r) => (release = r));
gate = { promise, release };
return release;
},
};
}
@ -138,6 +150,27 @@ test("restart failure evicts and notifies with no client", async () => {
expect(pool.clientFor("/a.op")).toBeUndefined();
});
test("releasing during a respawn disposes the newly spawned daemon (no orphan)", async () => {
const { pool, spawned, gateNextSpawn } = poolWithSpawns();
const restarts: Array<boolean> = [];
pool.onRestart((_f, client) => restarts.push(client !== undefined));
await pool.acquire("/a.op", "o");
// Crash A: handleCrash starts respawning, but the spawn is gated (in flight).
const releaseSpawn = gateNextSpawn();
(spawned[0] as unknown as FakeClient).crash();
await new Promise((r) => setTimeout(r, 0)); // let handleCrash reach the await
// User closes the panel now → release during the respawn window.
await pool.release("/a.op");
// Now the respawn completes.
releaseSpawn();
await new Promise((r) => setTimeout(r, 0));
// A second daemon WAS spawned, but must have been disposed immediately.
expect(spawned.length).toBe(2);
expect((spawned[1] as unknown as FakeClient).disposed).toBe(true);
expect(pool.clientFor("/a.op")).toBeUndefined(); // no lingering entry
expect(restarts).toEqual([]); // no restart-with-client emitted for an owner-less daemon
});
test("dispose()-driven exit does not trigger a restart", async () => {
const { pool, spawned } = poolWithSpawns();
let restartCalls = 0;

View file

@ -121,6 +121,14 @@ export class DaemonPool {
this.logger.error(`daemon for ${filePath} exited; restarting once`);
try {
const client = await this.spawnFn(filePath, entry.allowOrigin);
// The file may have been released (or the pool disposed) DURING the
// respawn — both mark the crashed entry disposed. If so, the freshly
// spawned daemon has no owner: dispose it immediately instead of adopting
// it, otherwise it orphans (alive, no entry, no mount).
if (entry.disposed || this.entries.get(filePath) !== entry) {
await client.dispose();
return;
}
const next: Entry = { client, allowOrigin: entry.allowOrigin, restarts: entry.restarts + 1, disposed: false };
this.entries.set(filePath, next);
this.wireExit(filePath, next);

View file

@ -1,12 +1,283 @@
// Activation assembly. Single registration topology (commands + one editor
// provider registered exactly once), late-bound through a mutable AppState so
// the limited→trusted transition never re-registers anything. Untrusted
// workspaces get a restricted placeholder; granting trust assembles the full
// stack and reopens placeholder tabs.
import * as vscode from "vscode";
import type { DaemonLogger } from "./daemon/daemon-client";
import { DaemonClient } from "./daemon/daemon-client";
import { DaemonPool } from "./daemon/daemon-pool";
import { McpProxy } from "./mcp/mcp-proxy";
import { SessionRegistry } from "./session/session-registry";
import { PenEditorProvider } from "./vscode/pen-editor-provider";
import { resolveDaemonBinary } from "./vscode/restart-source";
import { configureMcpCommand, removeMcpCommand } from "./vscode/configure-command";
import { installSkillCommand, removeSkillCommand } from "./vscode/skill-command";
import { generateCodeCommand, registerChatParticipant } from "./vscode/codegen-command";
let outputChannel: vscode.OutputChannel | undefined;
const PROXY_PORT_KEY = "openpencil.proxyPort";
export function activate(_context: vscode.ExtensionContext): void {
outputChannel = vscode.window.createOutputChannel("OpenPencil");
interface Assembled {
pool: DaemonPool;
proxy: McpProxy;
registry: SessionRegistry;
proxyPort: number;
}
export function deactivate(): void {
outputChannel?.dispose();
outputChannel = undefined;
interface AppState {
trusted: boolean;
assembled?: Assembled;
/** In-flight assembly, so the provider can await it instead of falling back
* to the restricted placeholder when a trusted workspace opens a .op file
* before assembly finished. */
assembling?: Promise<void>;
}
let output: vscode.OutputChannel | undefined;
const placeholderUris: vscode.Uri[] = [];
export function activate(context: vscode.ExtensionContext): void {
output = vscode.window.createOutputChannel("OpenPencil");
const logger: DaemonLogger = {
info: (l) => output?.appendLine(l),
error: (l) => output?.appendLine(`[error] ${l}`),
};
const state: AppState = { trusted: vscode.workspace.isTrusted };
currentState = state;
// Commands are registered ONCE; handlers dispatch through the mutable state.
registerCommands(context, state);
// The editor provider is registered ONCE; it branches on state.trusted.
const provider = new UnifiedPenProvider(context, state, logger);
context.subscriptions.push(
vscode.window.registerCustomEditorProvider("openpencil.penEditor", provider, {
supportsMultipleEditorsPerDocument: false,
webviewOptions: { retainContextWhenHidden: true },
}),
);
if (state.trusted) {
state.assembling = assemble(context, state, logger);
void state.assembling;
} else {
context.subscriptions.push(
vscode.workspace.onDidGrantWorkspaceTrust(() => {
void onTrustGranted(context, state, logger);
}),
);
}
}
export async function deactivate(): Promise<void> {
// Await the daemon shutdown chain so no managed daemon is orphaned.
if (currentState?.assembled) {
await currentState.assembled.proxy.dispose();
await currentState.assembled.pool.disposeAll();
}
output?.dispose();
output = undefined;
}
let currentState: AppState | undefined;
// ---- assembly ----
async function assemble(
context: vscode.ExtensionContext,
state: AppState,
logger: DaemonLogger,
): Promise<void> {
if (state.assembled) return; // idempotent guard
const registry = new SessionRegistry();
const spawn = makeSpawn(logger);
const pool = new DaemonPool(spawn, logger);
const proxy = new McpProxy(pool, logger);
const preferred =
context.workspaceState.get<number>(PROXY_PORT_KEY) ??
vscode.workspace.getConfiguration("openpencil").get<number>("proxy.port", 0);
let proxyPort: number;
try {
proxyPort = await proxy.listen(preferred);
} catch {
proxyPort = await proxy.listen(0); // preferred taken → OS-assign
void vscode.window.showInformationMessage(
"OpenPencil: the saved MCP port was busy; re-run 'Configure AI (MCP)' to update your config.",
);
}
await context.workspaceState.update(PROXY_PORT_KEY, proxyPort);
state.assembled = { pool, proxy, registry, proxyPort };
context.subscriptions.push(
new vscode.Disposable(() => {
void proxy.dispose();
void pool.disposeAll();
}),
);
const participant = registerChatParticipant(registry);
if (participant) context.subscriptions.push(participant);
}
async function onTrustGranted(
context: vscode.ExtensionContext,
state: AppState,
logger: DaemonLogger,
): Promise<void> {
if (state.assembled) return;
state.trusted = true;
await assemble(context, state, logger);
// VS Code does not re-resolve already-open custom editors, so reopen the
// placeholder tabs to pick up the now-trusted provider branch.
for (const uri of placeholderUris.splice(0)) {
await closeTabFor(uri);
await vscode.commands.executeCommand("vscode.openWith", uri, "openpencil.penEditor");
}
}
/** DaemonClient spawn factory: resolves the binary per file's workspace. */
function makeSpawn(logger: DaemonLogger) {
return async (filePath: string, allowOrigin: string) => {
const uri = vscode.Uri.file(filePath);
const folder = vscode.workspace.getWorkspaceFolder(uri) ?? vscode.workspace.workspaceFolders?.[0];
const configured = vscode.workspace.getConfiguration("openpencil").get<string>("dev.daemonPath");
const binary = resolveDaemonBinary(configured, folder?.uri.fsPath);
if (!binary) throw new Error("op-host-web-server binary not found; set openpencil.dev.daemonPath");
return DaemonClient.spawn({
command: [binary],
filePath,
allowOrigin,
logger,
expectedVersion: undefined,
});
};
}
// ---- unified provider (trusted branch delegates to PenEditorProvider) ----
class UnifiedPenProvider implements vscode.CustomEditorProvider<vscode.CustomDocument> {
private readonly emitter = new vscode.EventEmitter<
vscode.CustomDocumentContentChangeEvent<vscode.CustomDocument>
>();
readonly onDidChangeCustomDocument = this.emitter.event;
private real?: PenEditorProvider;
constructor(
private readonly context: vscode.ExtensionContext,
private readonly state: AppState,
private readonly logger: DaemonLogger,
) {}
/** Trusted → await any in-flight assembly, then the real provider. Untrusted
* → undefined (placeholder). Never returns undefined for a trusted workspace
* just because assembly hasn't finished yet — that would strand a valid file
* on the restricted placeholder with no trust event ever coming. */
private async realProvider(): Promise<PenEditorProvider | undefined> {
if (!this.state.trusted) return undefined;
if (!this.state.assembled && this.state.assembling) await this.state.assembling;
if (!this.state.assembled) return undefined; // assembly failed
if (!this.real) {
this.real = new PenEditorProvider(
this.context,
this.state.assembled.pool,
this.state.assembled.registry,
this.logger,
);
this.real.onDidChangeCustomDocument((e) =>
this.emitter.fire({ document: e.document as vscode.CustomDocument }),
);
}
return this.real;
}
async openCustomDocument(
uri: vscode.Uri,
openContext: vscode.CustomDocumentOpenContext,
_token: vscode.CancellationToken,
): Promise<vscode.CustomDocument> {
const real = await this.realProvider();
if (real) return real.openCustomDocument(uri, openContext);
// Untrusted only: a minimal placeholder document, tracked for the upgrade.
placeholderUris.push(uri);
return { uri, dispose: () => undefined };
}
async resolveCustomEditor(
document: vscode.CustomDocument,
panel: vscode.WebviewPanel,
_token: vscode.CancellationToken,
): Promise<void> {
const real = await this.realProvider();
if (real) return real.resolveCustomEditor(document as never, panel);
panel.webview.options = { enableScripts: true };
panel.webview.html = restrictedPage();
panel.webview.onDidReceiveMessage((m: unknown) => {
if (m === "trust") void vscode.commands.executeCommand("workbench.action.manageTrust");
});
}
// save/backup/revert only run after resolveCustomEditor set `this.real`
// (a placeholder document never becomes dirty/savable), so use it directly.
saveCustomDocument(document: vscode.CustomDocument, c: vscode.CancellationToken): Thenable<void> {
return this.real?.saveCustomDocument(document as never, c) ?? Promise.resolve();
}
saveCustomDocumentAs(
document: vscode.CustomDocument,
dest: vscode.Uri,
c: vscode.CancellationToken,
): Thenable<void> {
return this.real?.saveCustomDocumentAs(document as never, dest, c) ?? Promise.resolve();
}
revertCustomDocument(document: vscode.CustomDocument, _c: vscode.CancellationToken): Thenable<void> {
return this.real?.revertCustomDocument(document as never) ?? Promise.resolve();
}
backupCustomDocument(
document: vscode.CustomDocument,
ctx: vscode.CustomDocumentBackupContext,
c: vscode.CancellationToken,
): Thenable<vscode.CustomDocumentBackup> {
if (this.real) return this.real.backupCustomDocument(document as never, ctx, c);
return Promise.resolve({ id: ctx.destination.toString(), delete: () => undefined });
}
}
// ---- commands ----
function registerCommands(context: vscode.ExtensionContext, state: AppState): void {
const guard = (fn: (a: Assembled) => Promise<void> | void) => async () => {
if (!state.trusted || !state.assembled) {
void vscode.window.showWarningMessage("OpenPencil: trust this workspace to use this command.");
return;
}
await fn(state.assembled);
};
context.subscriptions.push(
vscode.commands.registerCommand("openpencil.configureMcp", guard((a) => configureMcpCommand(a.proxyPort))),
vscode.commands.registerCommand("openpencil.removeMcp", guard(() => removeMcpCommand())),
vscode.commands.registerCommand("openpencil.installSkill", guard(() => installSkillCommand(context))),
vscode.commands.registerCommand("openpencil.removeSkill", guard(() => removeSkillCommand())),
vscode.commands.registerCommand("openpencil.generateCode", guard((a) => generateCodeCommand(a.registry))),
);
}
// ---- pages ----
async function closeTabFor(uri: vscode.Uri): Promise<void> {
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
if (input?.uri?.toString() === uri.toString()) {
await vscode.window.tabGroups.close(tab);
}
}
}
}
function restrictedPage(): string {
return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem;color:var(--vscode-foreground)">
<h3>OpenPencil is in Restricted Mode</h3>
<p>This workspace isn't trusted, so OpenPencil won't start a local design daemon or write MCP config.</p>
<button onclick="acquireVsCodeApi().postMessage('trust')">Manage Workspace Trust…</button>
</body></html>`;
}

View file

@ -0,0 +1,156 @@
// "Generate Code from Design" command + the @openpencil chat participant. Both
// use vscode.lm; when it is unavailable, generateCode guides the user to their
// IDE's own chat with a pre-filled prompt. The prompt assembly and output
// validation live in the tested, vscode-free codegen-prompt module.
import * as vscode from "vscode";
import * as fsPath from "node:path";
import type { SessionRegistry } from "../session/session-registry";
import {
buildCodegenPrompt,
parseCodegenOutput,
type Framework,
} from "./codegen-prompt";
interface LmApi {
selectChatModels(selector?: unknown): Thenable<unknown[]>;
}
function lm(): LmApi | undefined {
return (vscode as unknown as { lm?: LmApi }).lm;
}
function framework(): Framework {
const v = vscode.workspace.getConfiguration("openpencil").get<string>("codegen.framework", "react");
return v === "vue" ? "vue" : "react";
}
async function activeDocJson(registry: SessionRegistry): Promise<string | undefined> {
const session = registry.activeSession();
if (!session) {
void vscode.window.showWarningMessage("OpenPencil: open and focus a .op design first.");
return undefined;
}
const bytes = await session.backup();
return new TextDecoder().decode(bytes);
}
export async function generateCodeCommand(registry: SessionRegistry): Promise<void> {
const docJson = await activeDocJson(registry);
if (docJson === undefined) return;
const prompt = buildCodegenPrompt(docJson, framework());
const api = lm();
if (!api) {
// No language-model API: hand the prompt to the IDE's own chat.
await vscode.env.clipboard.writeText(prompt);
void vscode.window.showInformationMessage(
"OpenPencil: this IDE has no language-model API. The codegen prompt is on your clipboard — paste it into your AI chat.",
);
return;
}
const models = (await api.selectChatModels()) as Array<{
sendRequest(messages: unknown[], options?: unknown, token?: unknown): Thenable<{ text: AsyncIterable<string> }>;
}>;
if (models.length === 0) {
void vscode.window.showWarningMessage("OpenPencil: no language model available.");
return;
}
const model = models[0];
const text = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "OpenPencil: generating code…", cancellable: true },
async (_progress, token) => {
const LanguageModelChatMessage = (
vscode as unknown as { LanguageModelChatMessage: { User(text: string): unknown } }
).LanguageModelChatMessage;
const res = await model.sendRequest([LanguageModelChatMessage.User(prompt)], {}, token);
let acc = "";
for await (const chunk of res.text) acc += chunk;
return acc;
},
);
const parsed = parseCodegenOutput(text);
if (!parsed.ok) {
const doc = await vscode.workspace.openTextDocument({ content: text, language: "markdown" });
await vscode.window.showTextDocument(doc);
void vscode.window.showWarningMessage(
`OpenPencil: could not parse generated files (${parsed.errors[0]}). Raw output opened for you.`,
);
return;
}
const dir = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
title: "Where should the generated files go?",
});
if (!dir || dir.length === 0) return;
const outRoot = dir[0];
for (const file of parsed.files) {
const target = vscode.Uri.file(fsPath.join(outRoot.fsPath, file.path));
if (await existsFs(target)) {
const overwrite = await vscode.window.showWarningMessage(
`${file.path} exists. Overwrite?`,
"Overwrite",
"Skip",
);
if (overwrite !== "Overwrite") continue;
}
await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(target.fsPath)));
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(file.content));
}
void vscode.window.showInformationMessage(`OpenPencil: wrote ${parsed.files.length} file(s).`);
}
/** Register @openpencil as a read-only design advisor when the chat + lm APIs
* exist. Returns a disposable, or undefined when the APIs are absent. */
export function registerChatParticipant(
registry: SessionRegistry,
): vscode.Disposable | undefined {
const chat = (vscode as unknown as { chat?: { createChatParticipant?: unknown } }).chat;
if (!lm() || !chat || typeof chat.createChatParticipant !== "function") return undefined;
type ChatHandler = (
request: { prompt: string },
_ctx: unknown,
response: { markdown(v: string): void },
token: unknown,
) => Promise<void>;
const handler: ChatHandler = async (request, _ctx, response, token) => {
const session = registry.activeSession();
if (!session) {
response.markdown("Open and focus a `.op` design first, then ask again.");
return;
}
const docJson = new TextDecoder().decode(await session.backup());
const api = lm() as unknown as {
selectChatModels(): Thenable<Array<{ sendRequest(m: unknown[], o?: unknown, t?: unknown): Thenable<{ text: AsyncIterable<string> }> }>>;
};
const models = await api.selectChatModels();
if (models.length === 0) {
response.markdown("No language model is available.");
return;
}
const LanguageModelChatMessage = (
vscode as unknown as { LanguageModelChatMessage: { User(text: string): unknown } }
).LanguageModelChatMessage;
const prompt = `You are a read-only advisor for this OpenPencil design. Answer the question; do not attempt to modify the design.\n\nDesign:\n${docJson}\n\nQuestion: ${request.prompt}`;
const res = await models[0].sendRequest([LanguageModelChatMessage.User(prompt)], {}, token);
for await (const chunk of res.text) response.markdown(chunk);
};
const create = chat.createChatParticipant as (id: string, h: ChatHandler) => vscode.Disposable;
return create("openpencil.assistant", handler);
}
async function existsFs(uri: vscode.Uri): Promise<boolean> {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,173 @@
// The "Configure AI (MCP)" and "Remove MCP" command shells. Drives the tested,
// vscode-free mcp-config adapters (detectIde/adapterFor/upsert/remove) with the
// diff-confirm UX, workspace-folder resolution, and reload prompt.
import * as vscode from "vscode";
import * as fsPath from "node:path";
import {
adapterFor,
detectIde,
McpConfigParseError,
type McpAdapter,
} from "../mcp/mcp-config";
export function proxyUrl(port: number): string {
return `http://127.0.0.1:${port}/mcp`;
}
function currentAdapter(root: string): McpAdapter {
const kind = detectIde({
appName: vscode.env.appName,
hasDir: (rel) => existsSyncQuiet(vscode.Uri.file(fsPath.join(root, rel))),
});
return adapterFor(kind);
}
/** Resolve the workspace folder to write the config into: the active .op file's
* folder if any, else a single root, else a QuickPick. */
async function pickWorkspaceRoot(): Promise<string | undefined> {
const folders = vscode.workspace.workspaceFolders ?? [];
if (folders.length === 0) {
void vscode.window.showErrorMessage("OpenPencil: open a workspace folder first.");
return undefined;
}
const active = vscode.window.activeTextEditor?.document.uri;
if (active) {
const f = vscode.workspace.getWorkspaceFolder(active);
if (f) return f.uri.fsPath;
}
if (folders.length === 1) return folders[0].uri.fsPath;
const pick = await vscode.window.showQuickPick(
folders.map((f) => ({ label: f.name, description: f.uri.fsPath })),
{ title: "Configure OpenPencil MCP in which folder?" },
);
return pick?.description;
}
export async function configureMcpCommand(port: number): Promise<void> {
if (!vscode.workspace.isTrusted) {
void vscode.window.showWarningMessage("OpenPencil: MCP config needs a trusted workspace.");
return;
}
const root = await pickWorkspaceRoot();
if (!root) return;
const adapter = currentAdapter(root);
const target = vscode.Uri.file(adapter.configPath(root));
const existing = await readTextOrNull(target);
let next: string;
try {
next = adapter.upsert(existing, proxyUrl(port));
} catch (err) {
if (err instanceof McpConfigParseError) {
void vscode.window.showErrorMessage(
`OpenPencil: ${target.fsPath} is not valid JSON — please fix it by hand.`,
);
return;
}
throw err;
}
if (next === existing) {
void vscode.window.showInformationMessage("OpenPencil MCP is already configured.");
return;
}
const choice = await vscode.window.showInformationMessage(
`Configure OpenPencil MCP in ${adapter.kind} (${target.fsPath})?`,
"Show diff",
"Write",
);
if (choice === "Show diff") {
await showDiff(target, existing ?? "", next);
const confirm = await vscode.window.showInformationMessage("Write this MCP config?", "Write");
if (confirm !== "Write") return;
} else if (choice !== "Write") {
return;
}
await writeConfig(target, next);
if (adapter.needsReload) {
void vscode.window.showInformationMessage(
"OpenPencil MCP written. Reload the window for the AI agent to pick it up.",
"Reload",
).then((r) => {
if (r === "Reload") void vscode.commands.executeCommand("workbench.action.reloadWindow");
});
} else {
void vscode.window.showInformationMessage("OpenPencil MCP configured.");
}
}
export async function removeMcpCommand(): Promise<void> {
const root = await pickWorkspaceRoot();
if (!root) return;
const adapter = currentAdapter(root);
const target = vscode.Uri.file(adapter.configPath(root));
const existing = await readTextOrNull(target);
let next: string | null;
try {
next = adapter.remove(existing);
} catch (err) {
if (err instanceof McpConfigParseError) {
void vscode.window.showErrorMessage(`OpenPencil: ${target.fsPath} is not valid JSON.`);
return;
}
throw err;
}
if (next === null) {
void vscode.window.showInformationMessage("OpenPencil MCP entry not present.");
return;
}
const confirm = await vscode.window.showInformationMessage(
`Remove the OpenPencil MCP entry from ${target.fsPath}?`,
"Remove",
);
if (confirm !== "Remove") return;
await writeConfig(target, next);
void vscode.window.showInformationMessage("OpenPencil MCP entry removed.");
}
// ---- helpers ----
async function readTextOrNull(uri: vscode.Uri): Promise<string | null> {
try {
return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
} catch {
return null;
}
}
async function writeConfig(uri: vscode.Uri, text: string): Promise<void> {
try {
await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(uri.fsPath)));
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(text));
} catch (err) {
void vscode.window.showErrorMessage(`OpenPencil: failed to write ${uri.fsPath}: ${String(err)}`);
throw err;
}
}
async function showDiff(target: vscode.Uri, before: string, after: string): Promise<void> {
const left = target.with({ scheme: "untitled", path: `${target.path}.current` });
const right = target.with({ scheme: "untitled", path: `${target.path}.proposed` });
await vscode.workspace.openTextDocument(left).then((d) => applyText(d, before));
await vscode.workspace.openTextDocument(right).then((d) => applyText(d, after));
await vscode.commands.executeCommand("vscode.diff", left, right, "OpenPencil MCP: current ↔ proposed");
}
async function applyText(doc: vscode.TextDocument, text: string): Promise<void> {
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(0, 0), text);
await vscode.workspace.applyEdit(edit);
}
function existsSyncQuiet(uri: vscode.Uri): boolean {
try {
// fs.stat is async; for the detect probe we accept a best-effort require.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodeFs = require("node:fs") as typeof import("node:fs");
return nodeFs.existsSync(uri.fsPath);
} catch {
return false;
}
}

View file

@ -0,0 +1,420 @@
// CustomEditorProvider for .op files. Wires the tested cores (DaemonPool,
// PenSession, SessionRegistry, webview shell) to the VS Code custom-editor
// lifecycle. This is the vscode-coupled glue layer — verified by the Task 12
// integration smoke + manual matrix, not unit tests. The security-sensitive and
// protocol-critical logic all lives in the injected, unit-tested cores.
import * as vscode from "vscode";
import * as path from "node:path";
import type { DaemonClient, DaemonLogger } from "../daemon/daemon-client";
import { DaemonHttp } from "../daemon/daemon-http";
import type { DaemonPool } from "../daemon/daemon-pool";
import { PenSession, type SessionHost } from "../session/pen-session";
import type { SessionRegistry } from "../session/session-registry";
import type { BridgeOutboundToPage } from "../protocol/bridge";
import { encodeOutbound } from "../protocol/bridge";
import { buildBootHtml, buildWebviewHtml } from "./webview-shell";
import { pickRestartSource, resolveDaemonBinary, type LatestDurable } from "./restart-source";
const SHELL_READY_TIMEOUT_MS = 5_000;
const DAEMON_READY_TIMEOUT_MS = 10_000;
const DAEMON_READY_POLL_MS = 200;
const WATCHER_DEBOUNCE_MS = 300;
export class PenDocument implements vscode.CustomDocument {
session?: PenSession;
latestDurable: LatestDurable;
constructor(
readonly uri: vscode.Uri,
readonly bootJson: string,
initialSource: LatestDurable["source"],
) {
this.latestDurable = { source: initialSource, json: bootJson };
}
dispose(): void {
this.session?.dispose();
this.session = undefined;
}
}
export class PenEditorProvider implements vscode.CustomEditorProvider<PenDocument> {
private readonly onDidChange = new vscode.EventEmitter<
vscode.CustomDocumentContentChangeEvent<PenDocument>
>();
readonly onDidChangeCustomDocument = this.onDidChange.event;
// One live mount per file. A single pool.onRestart handler (registered once)
// looks up the mount and rebuilds it, disposing the previous mount's
// listeners/watcher first so nothing accumulates across restarts.
private readonly mounts = new Map<string, MountContext>();
constructor(
private readonly context: vscode.ExtensionContext,
private readonly pool: DaemonPool,
private readonly registry: SessionRegistry,
private readonly logger: DaemonLogger,
) {
this.pool.onRestart((filePath, client) => void this.handleRestart(filePath, client));
}
async openCustomDocument(
uri: vscode.Uri,
openContext: vscode.CustomDocumentOpenContext,
): Promise<PenDocument> {
if (openContext.backupId) {
// Hot-exit recovery: the backup bytes are authoritative and must survive
// a subsequent daemon restart, so mark the durable source as "backup".
const bytes = await vscode.workspace.fs.readFile(vscode.Uri.parse(openContext.backupId));
return new PenDocument(uri, decode(bytes), "backup");
}
const bytes = await vscode.workspace.fs.readFile(uri);
return new PenDocument(uri, decode(bytes), "disk");
}
async resolveCustomEditor(document: PenDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true };
const key = document.uri.fsPath;
const watcherState = { suppressUntil: 0 };
// Terminal teardown anchored to the PANEL (registered once, survives
// restarts): whenever the panel closes, tear down the live mount if any and
// ALWAYS release the daemon — the single guarantee that closing an editor
// (even mid-restart, even after a failed re-mount) never orphans a daemon.
panel.onDidDispose(() => {
const live = this.mounts.get(key);
if (live && live.panel === panel) {
this.disposeMount(live);
this.mounts.delete(key);
}
void this.pool.release(key);
});
try {
const shellOrigin = await this.bootShellAndGetOrigin(panel);
const client = await this.spawnAndAwaitReady(document.uri, shellOrigin);
await this.mountEditor(document, panel, client, watcherState);
} catch (err) {
this.logger.error(`resolveCustomEditor failed: ${String(err)}`);
panel.webview.html = errorPage(String(err));
void this.pool.release(key); // failed initial mount → don't orphan
}
}
/** Phase 1: install the message listener + timeout BEFORE assigning boot HTML
* (the shell script may run synchronously on html assignment). */
private bootShellAndGetOrigin(panel: vscode.WebviewPanel): Promise<string> {
return new Promise<string>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
sub.dispose();
reject(new Error("webview shell did not report ready"));
}, SHELL_READY_TIMEOUT_MS);
const sub = panel.webview.onDidReceiveMessage((raw: unknown) => {
const origin = parseShellReadyOrigin(raw);
if (origin === undefined || settled) return;
settled = true;
clearTimeout(timer);
sub.dispose();
resolve(origin);
});
panel.webview.html = buildBootHtml(makeNonce());
});
}
private async spawnAndAwaitReady(uri: vscode.Uri, shellOrigin: string): Promise<DaemonClient> {
const client = await this.pool.acquire(uri.fsPath, shellOrigin);
const http = new DaemonHttp(client.baseUrl, client.handshake.token);
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
for (;;) {
if (await http.ready().catch(() => false)) return client;
if (Date.now() > deadline) throw new Error("daemon did not become ready");
await delay(DAEMON_READY_POLL_MS);
}
}
/** Phase 2: rebuild the full shell around the daemon iframe, wire the session
* and all lifecycle plumbing, and start the init loop. Every listener/watcher
* is collected into the mount's disposable set so a restart (or panel close)
* tears them all down with no accumulation. */
private async mountEditor(
document: PenDocument,
panel: vscode.WebviewPanel,
client: DaemonClient,
watcherState: { suppressUntil: number },
): Promise<void> {
const key = document.uri.fsPath;
const nonce = makeNonce();
const external = await vscode.env.asExternalUri(vscode.Uri.parse(`${client.baseUrl}/`));
panel.webview.html = buildWebviewHtml({ iframeSrc: external.toString(), nonce });
const host = this.makeHost(document, panel, watcherState);
const session = new PenSession(host, client.handshake.token, document.bootJson);
document.session = session;
this.registry.register(key, session);
const disposables: vscode.Disposable[] = [];
disposables.push(
panel.webview.onDidReceiveMessage((raw: unknown) => {
if (isShellControl(raw)) return;
session.onPageMessage(raw);
}),
);
if (panel.active) this.setActive(key);
disposables.push(
panel.onDidChangeViewState((e) => {
if (e.webviewPanel.active) this.setActive(key);
else if (this.pool.active?.filePath === key) this.setActive(undefined);
}),
);
// File watcher (GlobPattern required — Uri is not accepted).
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(path.dirname(key), path.basename(key)),
);
const onExternal = async () => {
if (Date.now() < watcherState.suppressUntil) return; // our own write
try {
const bytes = await vscode.workspace.fs.readFile(document.uri);
await session.externalFileChanged(decode(bytes), session.isRustDirty);
} catch (err) {
this.logger.error(`external change handling failed: ${String(err)}`);
}
};
disposables.push(watcher, watcher.onDidChange(onExternal), watcher.onDidCreate(onExternal));
// Terminal teardown (daemon release + mount disposal on panel close) is
// anchored at the PANEL scope in resolveCustomEditor — registered once so it
// survives restarts. Here we only record the live mount.
const ctx: MountContext = { document, panel, watcherState, session, disposables };
this.mounts.set(key, ctx);
session.start();
}
private disposeMount(ctx: MountContext): void {
for (const d of ctx.disposables) {
try {
d.dispose();
} catch {
/* best-effort */
}
}
ctx.disposables.length = 0;
this.registry.unregister(ctx.document.uri.fsPath);
ctx.session.dispose();
}
/** Single pool.onRestart handler: rebuild the live mount for this file with
* the correct recovery bytes, disposing the old mount's plumbing first. */
private async handleRestart(filePath: string, client: DaemonClient | undefined): Promise<void> {
const ctx = this.mounts.get(filePath);
if (!ctx) return; // no live editor for this file
const { document, panel } = ctx;
const wasDirty = ctx.session.isRustDirty;
this.disposeMount(ctx);
this.mounts.delete(filePath);
// Release the daemon on EVERY non-success path (repeated crash, or a failed
// re-mount) so a broken restart can't leave a live daemon with no owning
// mount. On success the fresh mount owns it (released at panel close). The
// panel-scope onDidDispose in resolveCustomEditor is the final backstop;
// pool.release is idempotent so a later close is a harmless no-op.
let remounted = false;
try {
if (!client) {
panel.webview.html = errorPage("The design daemon crashed repeatedly.");
return;
}
const source = pickRestartSource(document.latestDurable, wasDirty);
const bootJson =
source.from === "durable"
? source.json
: decode(await vscode.workspace.fs.readFile(document.uri));
(document as { bootJson: string }).bootJson = bootJson;
const shellOrigin = await this.bootShellAndGetOrigin(panel);
const fresh = await this.spawnAndAwaitReady(document.uri, shellOrigin);
await this.mountEditor(document, panel, fresh, { suppressUntil: 0 });
remounted = true;
} catch (err) {
panel.webview.html = errorPage(`restart failed: ${String(err)}`);
} finally {
if (!remounted) void this.pool.release(filePath);
}
}
private makeHost(
document: PenDocument,
panel: vscode.WebviewPanel,
watcherState: { suppressUntil: number },
): SessionHost {
const post = (msg: BridgeOutboundToPage) => {
void panel.webview.postMessage(encodeOutbound(msg));
};
return {
postToPage: post,
contentChanged: () => this.onDidChange.fire({ document }),
writeFile: async (bytes) => {
watcherState.suppressUntil = Date.now() + WATCHER_DEBOUNCE_MS;
await atomicWrite(document.uri, bytes);
document.latestDurable = { source: "save", json: decode(bytes) };
},
writeBackup: async (name, bytes) => {
const uri = this.backupUri(name);
await vscode.workspace.fs.writeFile(uri, bytes);
},
writeBackupFallback: async (bytes) => {
const target = await vscode.window.showSaveDialog({
title: "Save your OpenPencil version",
saveLabel: "Save copy",
});
if (!target) throw new Error("backup save cancelled — bytes not persisted");
await vscode.workspace.fs.writeFile(target, bytes);
return target.fsPath;
},
showConflictDialog: async (serverVersion) => {
const pick = await vscode.window.showWarningMessage(
`This design changed elsewhere (v${serverVersion}) while you were editing. Keep which version?`,
{ modal: true },
"Keep my version",
"Take the other version",
);
if (pick === "Keep my version") return "use-local";
if (pick === "Take the other version") return "accept-remote";
return undefined;
},
showExternalChangeDialog: async () => {
const pick = await vscode.window.showWarningMessage(
"The file changed on disk while you had unsaved edits.",
{ modal: true },
"Reload from disk (discard mine)",
"Keep my version",
"Save disk copy elsewhere",
);
if (pick === "Reload from disk (discard mine)") return "reload";
if (pick === "Save disk copy elsewhere") return "save-disk-copy";
if (pick === "Keep my version") return "keep-local";
return undefined;
},
schedule: (fn, ms) => {
const t = setTimeout(fn, ms);
return () => clearTimeout(t);
},
warn: (message) => {
this.logger.error(message);
void vscode.window.showWarningMessage(`OpenPencil: ${message}`);
},
};
}
// ---- CustomEditorProvider save/backup/revert ----
saveCustomDocument(document: PenDocument, cancellation: vscode.CancellationToken): Thenable<void> {
return this.requireSession(document).save(() => cancellation.isCancellationRequested);
}
async saveCustomDocumentAs(
document: PenDocument,
destination: vscode.Uri,
cancellation: vscode.CancellationToken,
): Promise<void> {
const bytes = await this.requireSession(document).backup(() => cancellation.isCancellationRequested);
await atomicWrite(destination, bytes);
}
async revertCustomDocument(document: PenDocument): Promise<void> {
const bytes = await vscode.workspace.fs.readFile(document.uri);
const json = decode(bytes);
await this.requireSession(document).revert(json);
document.latestDurable = { source: "revert", json };
}
async backupCustomDocument(
document: PenDocument,
context: vscode.CustomDocumentBackupContext,
cancellation: vscode.CancellationToken,
): Promise<vscode.CustomDocumentBackup> {
const bytes = await this.requireSession(document).backup(() => cancellation.isCancellationRequested);
await vscode.workspace.fs.writeFile(context.destination, bytes);
document.latestDurable = { source: "backup", json: decode(bytes) };
return {
id: context.destination.toString(),
delete: () => void vscode.workspace.fs.delete(context.destination).then(undefined, () => {}),
};
}
// ---- helpers ----
private requireSession(document: PenDocument): PenSession {
if (!document.session) throw new Error("editor not ready");
return document.session;
}
private setActive(filePath: string | undefined): void {
this.pool.setActive(filePath);
this.registry.setActive(filePath);
}
private backupUri(name: string): vscode.Uri {
const dir = this.context.storageUri ?? this.context.globalStorageUri;
return vscode.Uri.joinPath(dir, name);
}
}
interface MountContext {
document: PenDocument;
panel: vscode.WebviewPanel;
watcherState: { suppressUntil: number };
session: PenSession;
disposables: vscode.Disposable[];
}
// ---- module helpers ----
const decoder = new TextDecoder();
function decode(bytes: Uint8Array): string {
return decoder.decode(bytes);
}
/** Atomic write: temp file + rename, so a crash mid-write can't truncate. */
async function atomicWrite(uri: vscode.Uri, bytes: Uint8Array): Promise<void> {
const tmp = uri.with({ path: `${uri.path}.op-tmp` });
await vscode.workspace.fs.writeFile(tmp, bytes);
await vscode.workspace.fs.rename(tmp, uri, { overwrite: true });
}
function makeNonce(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Parse an op-shell/ready control message → its reported origin, else undefined. */
function parseShellReadyOrigin(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
try {
const v = JSON.parse(raw) as { type?: unknown; origin?: unknown };
if (v.type === "op-shell/ready" && typeof v.origin === "string") return v.origin;
} catch {
/* not a control message */
}
return undefined;
}
function isShellControl(raw: unknown): boolean {
return typeof raw === "string" && raw.includes("op-shell/");
}
function errorPage(detail: string): string {
const safe = detail.replace(/[<&]/g, (c) => (c === "<" ? "&lt;" : "&amp;"));
return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem;color:var(--vscode-foreground)">
<h3>OpenPencil could not open this design</h3><p>${safe}</p>
<p>Check that <code>op-host-web-server</code> is built (<code>cargo build -p op-host-web-server</code>) or set <code>openpencil.dev.daemonPath</code>.</p>
</body></html>`;
}
export { resolveDaemonBinary };

View file

@ -0,0 +1,39 @@
import { test, expect } from "bun:test";
import { pickRestartSource, resolveDaemonBinary } from "./restart-source";
test("dirty editor reopens durable bytes", () => {
const r = pickRestartSource({ source: "save", json: '{"d":1}' }, true);
expect(r).toEqual({ from: "durable", json: '{"d":1}' });
});
test("clean editor after save reopens from disk", () => {
const r = pickRestartSource({ source: "save", json: "{}" }, false);
expect(r).toEqual({ from: "disk" });
});
test("clean editor after revert reopens from disk", () => {
const r = pickRestartSource({ source: "revert", json: "{}" }, false);
expect(r).toEqual({ from: "disk" });
});
test("hot-exit backup (clean, source=backup) reopens durable, not disk", () => {
// The recovered content differs from disk even though Rust is clean — reading
// disk here would drop the recovery.
const r = pickRestartSource({ source: "backup", json: '{"recovered":1}' }, false);
expect(r).toEqual({ from: "durable", json: '{"recovered":1}' });
});
test("resolveDaemonBinary: explicit setting wins", () => {
expect(resolveDaemonBinary("/opt/op-host-web-server", "/ws")).toBe("/opt/op-host-web-server");
expect(resolveDaemonBinary(" /opt/x ", "/ws")).toBe("/opt/x");
});
test("resolveDaemonBinary: falls back to workspace target/debug", () => {
expect(resolveDaemonBinary("", "/ws")).toBe("/ws/target/debug/op-host-web-server");
expect(resolveDaemonBinary(undefined, "/ws")).toBe("/ws/target/debug/op-host-web-server");
});
test("resolveDaemonBinary: null when nothing available", () => {
expect(resolveDaemonBinary(undefined, undefined)).toBeNull();
expect(resolveDaemonBinary("", undefined)).toBeNull();
});

View file

@ -0,0 +1,37 @@
// Pure helpers for the provider's daemon-crash recovery and binary resolution.
// Kept vscode-free so the recovery decisions (which bytes to reopen after a
// crash; where the daemon binary lives) are unit-testable.
export type DurableSource = "disk" | "save" | "revert" | "backup";
export interface LatestDurable {
source: DurableSource;
json: string;
}
/** After a daemon crash, decide which bytes to reopen with. If the editor was
* dirty, OR the durable state came from a hot-exit backup (Rust side is clean
* but its content differs from disk), reopen the durable bytes — reading disk
* would silently drop the recovered content. Otherwise the on-disk file is the
* source of truth. */
export function pickRestartSource(
latest: LatestDurable,
isDirty: boolean,
): { from: "durable"; json: string } | { from: "disk" } {
if (isDirty || latest.source === "backup") {
return { from: "durable", json: latest.json };
}
return { from: "disk" };
}
/** Resolve the op-host-web-server command prefix: an explicit setting wins,
* otherwise <workspaceRoot>/target/debug/op-host-web-server. Returns null when
* neither is available (the caller shows a guidance page). */
export function resolveDaemonBinary(
configuredPath: string | undefined,
workspaceRoot: string | undefined,
): string | null {
const trimmed = configuredPath?.trim();
if (trimmed) return trimmed;
if (workspaceRoot) return `${workspaceRoot}/target/debug/op-host-web-server`;
return null;
}

View file

@ -0,0 +1,106 @@
// "Install AI Skill" / "Remove AI Skill" command shells. The skill markdown is
// extracted at build time into dist/assets/openpencil-skill.md (see build.mjs);
// this writes it into the current IDE's rules path. vscode uses MCP directly, so
// it needs no rules file.
import * as vscode from "vscode";
import * as fsPath from "node:path";
import { adapterFor, detectIde, type IdeKind } from "../mcp/mcp-config";
/** Rules file path per IDE (relative to the workspace root). null = the IDE
* drives OpenPencil purely through MCP and needs no rules file. */
function rulesRelPath(kind: IdeKind): string | null {
switch (kind) {
case "cursor":
return ".cursor/rules/openpencil.mdc";
case "trae":
return ".trae/rules/openpencil.md";
case "windsurf":
return ".windsurf/rules/openpencil.md";
case "vscode":
return null;
}
}
function detectKind(root: string): IdeKind {
return detectIde({
appName: vscode.env.appName,
hasDir: (rel) => existsQuiet(fsPath.join(root, rel)),
}).valueOf() as IdeKind;
}
async function firstRoot(): Promise<string | undefined> {
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
export async function installSkillCommand(context: vscode.ExtensionContext): Promise<void> {
const root = await firstRoot();
if (!root) {
void vscode.window.showErrorMessage("OpenPencil: open a workspace folder first.");
return;
}
const kind = detectKind(root);
// adapterFor is used only to assert the kind resolves to a known adapter.
adapterFor(kind);
const rel = rulesRelPath(kind);
if (rel === null) {
void vscode.window.showInformationMessage(
"On VS Code, OpenPencil works through MCP — no rules file needed. Run 'Configure AI (MCP)'.",
);
return;
}
const skill = await readSkillAsset(context);
if (skill === null) {
void vscode.window.showErrorMessage("OpenPencil: bundled skill asset missing from the extension.");
return;
}
const target = vscode.Uri.file(fsPath.join(root, rel));
const confirm = await vscode.window.showInformationMessage(
`Install the OpenPencil design skill for ${kind} at ${rel}?`,
"Install",
);
if (confirm !== "Install") return;
await vscode.workspace.fs.createDirectory(vscode.Uri.file(fsPath.dirname(target.fsPath)));
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(skill));
void vscode.window.showInformationMessage(`OpenPencil skill installed at ${rel}.`);
}
export async function removeSkillCommand(): Promise<void> {
const root = await firstRoot();
if (!root) return;
const rel = rulesRelPath(detectKind(root));
if (rel === null) {
void vscode.window.showInformationMessage("Nothing to remove on VS Code.");
return;
}
const target = vscode.Uri.file(fsPath.join(root, rel));
const confirm = await vscode.window.showInformationMessage(
`Remove the OpenPencil skill file at ${rel}?`,
"Remove",
);
if (confirm !== "Remove") return;
try {
await vscode.workspace.fs.delete(target);
void vscode.window.showInformationMessage("OpenPencil skill removed.");
} catch {
void vscode.window.showInformationMessage("OpenPencil skill file was not present.");
}
}
async function readSkillAsset(context: vscode.ExtensionContext): Promise<string | null> {
const uri = vscode.Uri.joinPath(context.extensionUri, "dist", "assets", "openpencil-skill.md");
try {
return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
} catch {
return null;
}
}
function existsQuiet(p: string): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return (require("node:fs") as typeof import("node:fs")).existsSync(p);
} catch {
return false;
}
}

View file

@ -0,0 +1,104 @@
// Cross-language integration smoke: drives the REAL op-host-web-server binary
// through the extension's own DaemonClient → DaemonHttp → McpProxy stack. It
// does not load VS Code — it exercises the daemon contract end to end.
//
// Requires `cargo build -p op-host-web-server`. Binary missing → the test
// FAILS (never skips) so a broken build is visible.
import { test, expect, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { DaemonClient, type DaemonLogger } from "../../src/daemon/daemon-client";
import { DaemonHttp } from "../../src/daemon/daemon-http";
import { McpProxy } from "../../src/mcp/mcp-proxy";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..", "..", ".."); // packages/op-vscode/test/integration → repo root
const BINARY = join(repoRoot, "target", "debug", "op-host-web-server");
const BUNDLE_DIR = join(repoRoot, "crates", "op-host-web", "pkg");
const silent: DaemonLogger = { info: () => {}, error: () => {} };
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const c of cleanups.splice(0)) await c();
});
test("real daemon: handshake → version → mcp-via-proxy → auth/origin defenses → clean exit", async () => {
expect(
existsSync(BINARY),
`op-host-web-server not built — run: cargo build -p op-host-web-server`,
).toBe(true);
// A minimal .op file to open.
const fixture = join(
repoRoot,
"vendor",
"jian",
"crates",
"jian-ops-schema",
"tests",
"corpus",
"nested-frame.op",
);
const client = await DaemonClient.spawn({
command: [BINARY],
filePath: existsSync(fixture) ? fixture : undefined,
allowOrigin: "vscode-webview://smoke",
logger: silent,
});
cleanups.push(() => client.dispose());
// Handshake produced a usable base URL + token.
expect(client.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(client.handshake.token.length).toBeGreaterThan(0);
const http = new DaemonHttp(client.baseUrl, client.handshake.token);
const version = await http.version();
expect(typeof version).toBe("number");
expect(version).toBeGreaterThanOrEqual(0);
// ready(): true only when the wasm bundle is present. Report honestly.
const ready = await http.ready();
if (existsSync(BUNDLE_DIR)) {
expect(ready).toBe(true);
} else {
expect(ready).toBe(false);
console.log("note: op-host-web/pkg bundle absent (needs EMSDK); ready()=false as expected");
}
// No-token direct hit on a privileged endpoint → 401.
const unauth = await fetch(`${client.baseUrl}/api/mcp/version`);
expect(unauth.status).toBe(401);
// MCP initialize through the proxy → a JSON-RPC result.
const proxy = new McpProxy(
{ active: { filePath: "x", client }, onActiveChanged: () => {} },
silent,
);
const port = await proxy.listen(0);
cleanups.push(() => proxy.dispose());
const proxyUrl = `http://127.0.0.1:${port}/mcp`;
const init = await fetch(proxyUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
});
expect(init.status).toBe(200);
const initBody = (await init.json()) as { result?: unknown };
expect(initBody.result).toBeDefined();
// A browser-style Origin header on the proxy → 403 (DNS-rebind defense).
const withOrigin = await fetch(proxyUrl, {
method: "POST",
headers: { "content-type": "application/json", origin: "http://evil.example" },
body: '{"jsonrpc":"2.0","id":2,"method":"initialize"}',
});
expect(withOrigin.status).toBe(403);
// dispose() closes stdin → the daemon self-exits.
await client.dispose();
expect(client.alive).toBe(false);
});