[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
957
electron/plugins/runtime/runtimePeer.mjs
Normal file
957
electron/plugins/runtime/runtimePeer.mjs
Normal file
@@ -0,0 +1,957 @@
|
||||
import {
|
||||
CancellationTokenSource,
|
||||
DisposableStore,
|
||||
PluginError,
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
|
||||
function loadTerminalInterceptorTransport() {
|
||||
terminalInterceptorTransportPromise ??= import("../terminalInterceptorTransport.cjs")
|
||||
.then((module) => module.default);
|
||||
return terminalInterceptorTransportPromise;
|
||||
}
|
||||
|
||||
const RPC_ERRORS = {
|
||||
methodNotFound: -32601,
|
||||
invalidParams: -32602,
|
||||
internal: -32603,
|
||||
cancelled: -32001,
|
||||
unsupported: -32012,
|
||||
};
|
||||
|
||||
const PLUGIN_ERROR_NAMES_BY_WIRE_CODE = new Map(
|
||||
Object.entries(PLUGIN_ERROR_WIRE_CODES).map(([name, code]) => [code, name]),
|
||||
);
|
||||
|
||||
const PROVIDER_KINDS = new Set([
|
||||
"terminal.completion",
|
||||
"terminal.decoration",
|
||||
"terminal.link",
|
||||
"terminal.hover",
|
||||
"terminal.matcher",
|
||||
"terminal.semantic",
|
||||
"terminal.prompt",
|
||||
"terminal.background",
|
||||
"terminal.theme",
|
||||
"terminal.interceptor.input",
|
||||
"terminal.interceptor.output",
|
||||
"connection",
|
||||
"authentication",
|
||||
"sync",
|
||||
"importer",
|
||||
]);
|
||||
|
||||
const CONNECTION_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"validateConfiguration",
|
||||
"probe",
|
||||
"open",
|
||||
"resize",
|
||||
"signal",
|
||||
"reconnect",
|
||||
"close",
|
||||
"getStatus",
|
||||
]);
|
||||
const IMPORTER_PROVIDER_OPERATIONS = Object.freeze(["detect", "parse"]);
|
||||
const SYNC_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"connect",
|
||||
"disconnect",
|
||||
"getAccount",
|
||||
"getCapabilities",
|
||||
"readObject",
|
||||
"writeObject",
|
||||
"deleteObject",
|
||||
]);
|
||||
const OPERATION_MAP_PROVIDER_KINDS = Object.freeze(new Set(["connection", "importer", "sync"]));
|
||||
|
||||
function pluginErrorNameFromRpcError(error) {
|
||||
if (typeof error?.data?.pluginCode === "string") return error.data.pluginCode;
|
||||
if (error?.code === RPC_ERRORS.methodNotFound) return "unsupported";
|
||||
if (error?.code === RPC_ERRORS.invalidParams) return "invalid_argument";
|
||||
if (error?.code === RPC_ERRORS.internal) return "internal";
|
||||
return PLUGIN_ERROR_NAMES_BY_WIRE_CODE.get(error?.code) ?? "unknown";
|
||||
}
|
||||
|
||||
function freezeRuntimeJson(value) {
|
||||
const clone = structuredClone(value);
|
||||
const freeze = (item) => {
|
||||
if (!item || typeof item !== "object" || Object.isFrozen(item)) return item;
|
||||
for (const child of Array.isArray(item) ? item : Object.values(item)) freeze(child);
|
||||
return Object.freeze(item);
|
||||
};
|
||||
return freeze(clone);
|
||||
}
|
||||
|
||||
function messageData(value) {
|
||||
return value && typeof value === "object" && "data" in value ? value.data : value;
|
||||
}
|
||||
|
||||
function closeTransferredPorts(ports) {
|
||||
for (const port of ports ?? []) port?.close?.();
|
||||
}
|
||||
|
||||
function createTransportAdapter(port) {
|
||||
const listeners = new Set();
|
||||
const handle = (event) => {
|
||||
const value = messageData(event);
|
||||
for (const listener of listeners) listener(value, event?.ports ?? []);
|
||||
};
|
||||
if (typeof port.addEventListener === "function") port.addEventListener("message", handle);
|
||||
else port.on("message", handle);
|
||||
port.start?.();
|
||||
return {
|
||||
post(message, transfer = []) {
|
||||
port.postMessage(message, transfer);
|
||||
},
|
||||
onMessage(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
listeners.clear();
|
||||
port.close?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRpcFailure(id, code, message, data) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code, message, ...(data === undefined ? {} : { data }) },
|
||||
};
|
||||
}
|
||||
|
||||
function cancelUnhandledStream(transport, message) {
|
||||
const keys = Reflect.ownKeys(message);
|
||||
if (keys.some((key) => typeof key !== "string" || (key !== "frame" && key !== "transfer"))) {
|
||||
throw new TypeError("Plugin stream envelope contains unknown properties");
|
||||
}
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("Plugin stream envelope must contain enumerable data properties");
|
||||
}
|
||||
}
|
||||
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
||||
if (envelope.frame.kind !== "open") {
|
||||
throw new Error(`Unknown incoming plugin stream: ${envelope.frame.streamId}`);
|
||||
}
|
||||
transport.post(createMessagePortStreamEnvelope({
|
||||
streamId: envelope.frame.streamId,
|
||||
sequence: 1,
|
||||
kind: "cancel",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeError(error) {
|
||||
if (error instanceof PluginError) {
|
||||
return pluginErrorToRpcError(error);
|
||||
}
|
||||
return { code: RPC_ERRORS.internal, message: "Plugin operation failed" };
|
||||
}
|
||||
|
||||
function createHostClient(transport) {
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
function accept(message) {
|
||||
if (!message || typeof message !== "object" || !Object.hasOwn(message, "id")) return false;
|
||||
if (!Object.hasOwn(message, "result") && !Object.hasOwn(message, "error")) return false;
|
||||
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
|
||||
if (!request) return false;
|
||||
pending.delete(`${typeof message.id}:${String(message.id)}`);
|
||||
if (message.error) {
|
||||
request.reject(new PluginError(
|
||||
pluginErrorNameFromRpcError(message.error),
|
||||
message.error.message,
|
||||
message.error.data?.details,
|
||||
));
|
||||
} else request.resolve(message.result);
|
||||
return true;
|
||||
}
|
||||
function request(method, params, options = {}) {
|
||||
const id = nextId;
|
||||
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
|
||||
transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
||||
});
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
transport.post({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
function close() {
|
||||
const error = new PluginError("unavailable", "Plugin host disconnected");
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
pending.clear();
|
||||
}
|
||||
return { accept, request, notify, close };
|
||||
}
|
||||
|
||||
function assertStorageKey(key) {
|
||||
if (typeof key !== "string" || key.length < 1 || key.length > 256 || key.includes("\0")) {
|
||||
throw new PluginError("invalid_argument", "Plugin storage key is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function assertCredentialRef(credential) {
|
||||
if (
|
||||
!credential
|
||||
|| typeof credential !== "object"
|
||||
|| (credential.kind !== "secret" && credential.kind !== "credential")
|
||||
|| typeof credential.id !== "string"
|
||||
|| credential.id.length < 16
|
||||
|| credential.id.length > 256
|
||||
) throw new PluginError("invalid_argument", "Credential reference is invalid");
|
||||
if (
|
||||
credential.kind === "secret"
|
||||
&& (
|
||||
typeof credential.key !== "string"
|
||||
|| credential.key.length < 1
|
||||
|| credential.key.length > 256
|
||||
|| credential.key.includes("\0")
|
||||
)
|
||||
) throw new PluginError("invalid_argument", "Credential reference is invalid");
|
||||
return credential.kind === "secret"
|
||||
? { kind: "secret", id: credential.id, key: credential.key }
|
||||
: { kind: "credential", id: credential.id };
|
||||
}
|
||||
|
||||
function forwardedDeadline(value, maximum) {
|
||||
return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : undefined;
|
||||
}
|
||||
|
||||
function assertCompanionId(companionId) {
|
||||
if (typeof companionId !== "string" || companionId.length < 5 || companionId.length > 192) {
|
||||
throw new PluginError("invalid_argument", "Companion ID is invalid");
|
||||
}
|
||||
return companionId;
|
||||
}
|
||||
|
||||
function assertOwnedContributionId(pluginId, id, label) {
|
||||
if (typeof id !== "string" || !id.startsWith(`${pluginId}.`) || id.length > 256) {
|
||||
throw new PluginError("invalid_argument", `${label} ID is invalid`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function assertProviderKind(kind) {
|
||||
if (!PROVIDER_KINDS.has(kind)) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider kind is invalid");
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationMapLabel(kind) {
|
||||
if (kind === "connection") return "Connection";
|
||||
if (kind === "importer") return "Importer";
|
||||
if (kind === "sync") return "Sync";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationsForProviderKind(kind) {
|
||||
if (kind === "connection") return CONNECTION_PROVIDER_OPERATIONS;
|
||||
if (kind === "importer") return IMPORTER_PROVIDER_OPERATIONS;
|
||||
if (kind === "sync") return SYNC_PROVIDER_OPERATIONS;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProviderHandler(kind, handler) {
|
||||
if (!OPERATION_MAP_PROVIDER_KINDS.has(kind)) {
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider handler must be a function");
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
const label = operationMapLabel(kind);
|
||||
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler must be an operation map`);
|
||||
}
|
||||
const normalized = {};
|
||||
const operations = operationsForProviderKind(kind);
|
||||
for (const operation of operations) {
|
||||
if (typeof handler[operation] !== "function") {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler is missing operation: ${operation}`);
|
||||
}
|
||||
normalized[operation] = handler[operation].bind(handler);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function assertOwnedContextKey(pluginId, key) {
|
||||
const prefix = `${pluginId}.`;
|
||||
const suffix = typeof key === "string" && key.startsWith(prefix)
|
||||
? key.slice(prefix.length)
|
||||
: "";
|
||||
if (!/^[A-Za-z0-9_][A-Za-z0-9_:-]{0,255}$/u.test(suffix)
|
||||
|| key.length > 256) {
|
||||
throw new PluginError("invalid_argument", "Plugin Context Key ID is invalid");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function normalizeRuntimeEnvironment(value) {
|
||||
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
const sourceTokens = source.themeTokens && typeof source.themeTokens === "object"
|
||||
&& !Array.isArray(source.themeTokens)
|
||||
? source.themeTokens
|
||||
: {};
|
||||
const themeTokens = Object.freeze(Object.fromEntries(
|
||||
Object.entries(sourceTokens).filter(([key, token]) => key.length > 0 && typeof token === "string"),
|
||||
));
|
||||
return Object.freeze({
|
||||
locale: typeof source.locale === "string" ? source.locale : "en",
|
||||
theme: typeof source.theme === "string" ? source.theme : "system",
|
||||
reducedMotion: source.reducedMotion === true,
|
||||
highContrast: source.highContrast === true,
|
||||
themeTokens,
|
||||
});
|
||||
}
|
||||
|
||||
function createEmitter() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
event(listener) {
|
||||
if (typeof listener !== "function") throw new PluginError("invalid_argument", "Plugin event listener must be a function");
|
||||
listeners.add(listener);
|
||||
return Object.freeze({ dispose: () => listeners.delete(listener) });
|
||||
},
|
||||
fire(value) {
|
||||
for (const listener of [...listeners]) {
|
||||
try { listener(value); } catch {}
|
||||
}
|
||||
},
|
||||
clear() { listeners.clear(); },
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginContext(config, client, runtimeApi) {
|
||||
const subscriptions = new DisposableStore();
|
||||
const storage = {
|
||||
get: (key) => client.request("storage.get", { key: assertStorageKey(key) })
|
||||
.then((result) => result?.found ? result.value : undefined),
|
||||
set: (key, value) => client.request("storage.set", { key: assertStorageKey(key), value })
|
||||
.then(() => undefined),
|
||||
delete: (key) => client.request("storage.delete", { key: assertStorageKey(key) })
|
||||
.then(() => undefined),
|
||||
keys: () => client.request("storage.keys", {}).then((result) => result?.keys ?? []),
|
||||
};
|
||||
const secrets = {
|
||||
get: (key) => client.request("secrets.get", { key: assertStorageKey(key) })
|
||||
.then((result) => result?.found ? result.secret : undefined),
|
||||
set: (key, value) => client.request("secrets.set", {
|
||||
key: assertStorageKey(key),
|
||||
value,
|
||||
}).then((result) => result.secret),
|
||||
delete: (key) => client.request("secrets.delete", { key: assertStorageKey(key) })
|
||||
.then(() => undefined),
|
||||
};
|
||||
const credentials = {
|
||||
createLease: (secret, options) => client.request("credentials.createLease", {
|
||||
secret: assertCredentialRef(secret),
|
||||
operationId: options?.operationId,
|
||||
purpose: options?.purpose,
|
||||
...(options?.ttlMs === undefined ? {} : { ttlMs: options.ttlMs }),
|
||||
}),
|
||||
};
|
||||
const network = {
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
}),
|
||||
};
|
||||
const filesystem = {
|
||||
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
|
||||
path: filePath,
|
||||
...options,
|
||||
}).then((result) => result.data),
|
||||
writeFile: (filePath, data, options = {}) => client.request("filesystem.writeFile", {
|
||||
path: filePath,
|
||||
data,
|
||||
...options,
|
||||
}).then(() => undefined),
|
||||
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
|
||||
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
|
||||
.then((result) => result.entries),
|
||||
};
|
||||
const companions = {
|
||||
start: async (companionId) => {
|
||||
const result = await client.request("companion.start", {
|
||||
companionId: assertCompanionId(companionId),
|
||||
});
|
||||
let stopped = false;
|
||||
let stopPromise = null;
|
||||
const stop = () => {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = client.request("companion.stop", { handleId: result.handleId })
|
||||
.then(() => { stopped = true; })
|
||||
.finally(() => { stopPromise = null; });
|
||||
return stopPromise;
|
||||
};
|
||||
return Object.freeze({
|
||||
id: result.handleId,
|
||||
request: (method, params, options = {}) => client.request(
|
||||
"companion.request",
|
||||
{
|
||||
handleId: result.handleId,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.credentialLeases === undefined ? {} : {
|
||||
credentialLeases: options.credentialLeases,
|
||||
operationId: options.operationId,
|
||||
}),
|
||||
},
|
||||
{ deadlineMs: forwardedDeadline(options.timeoutMs, 60_000) },
|
||||
),
|
||||
stop,
|
||||
dispose() { void stop().catch(() => {}); },
|
||||
});
|
||||
},
|
||||
};
|
||||
const streams = {
|
||||
acceptReadable: (streamId) => runtimeApi.streams.acceptReadable(streamId),
|
||||
openWritable: (streamId, options = {}) => runtimeApi.streams.openWritable(
|
||||
streamId,
|
||||
options.windowBytes,
|
||||
),
|
||||
};
|
||||
const settings = {
|
||||
get: (settingId, options = {}) => client.request("settings.get", {
|
||||
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
|
||||
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
|
||||
}).then((result) => result?.found ? result.value : undefined),
|
||||
update: (settingId, value, options = {}) => client.request("settings.update", {
|
||||
settingId: assertOwnedContributionId(config.pluginId, settingId, "Plugin setting"),
|
||||
value,
|
||||
...(options.scopeId === undefined ? {} : { scopeId: options.scopeId }),
|
||||
}).then((result) => result ?? { restartRequired: false }),
|
||||
onDidChange: runtimeApi.settingsChanged.event,
|
||||
};
|
||||
const commands = {
|
||||
registerCommand(commandId, handler) {
|
||||
const id = assertOwnedContributionId(config.pluginId, commandId, "Plugin command");
|
||||
if (typeof handler !== "function") throw new PluginError("invalid_argument", "Plugin command handler must be a function");
|
||||
if (runtimeApi.commandHandlers.has(id)) throw new PluginError("already_exists", `Plugin command is already registered: ${id}`);
|
||||
runtimeApi.commandHandlers.set(id, handler);
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (runtimeApi.commandHandlers.get(id) === handler) {
|
||||
runtimeApi.commandHandlers.delete(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
executeCommand: (commandId, args) => client.request("commands.execute", {
|
||||
command: assertOwnedContributionId(config.pluginId, commandId, "Plugin command"),
|
||||
...(args === undefined ? {} : { args }),
|
||||
}),
|
||||
};
|
||||
const contextKeys = {
|
||||
set: (key, value) => client.request("contextKeys.set", {
|
||||
key: assertOwnedContextKey(config.pluginId, key),
|
||||
value,
|
||||
}).then(() => undefined),
|
||||
};
|
||||
const views = {
|
||||
onDidReceiveMessage(viewId, listener) {
|
||||
const id = assertOwnedContributionId(config.pluginId, viewId, "Plugin view");
|
||||
let emitter = runtimeApi.viewMessages.get(id);
|
||||
if (!emitter) {
|
||||
emitter = createEmitter();
|
||||
runtimeApi.viewMessages.set(id, emitter);
|
||||
}
|
||||
return emitter.event(listener);
|
||||
},
|
||||
postMessage: (viewId, message) => client.notify("views.postMessage", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
message,
|
||||
}),
|
||||
getState: (viewId, scopeId) => client.request("views.getState", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
scopeId,
|
||||
}).then((result) => result?.state),
|
||||
setState: (viewId, scopeId, state) => client.request("views.setState", {
|
||||
viewId: assertOwnedContributionId(config.pluginId, viewId, "Plugin view"),
|
||||
scopeId,
|
||||
state,
|
||||
}).then(() => undefined),
|
||||
};
|
||||
const providers = {
|
||||
register(providerId, kind, handler) {
|
||||
const id = assertOwnedContributionId(config.pluginId, providerId, "Plugin Provider");
|
||||
const normalizedKind = assertProviderKind(kind);
|
||||
const normalizedHandler = normalizeProviderHandler(normalizedKind, handler);
|
||||
if (runtimeApi.providerHandlers.has(id)) {
|
||||
throw new PluginError("already_exists", `Plugin Provider is already registered: ${id}`);
|
||||
}
|
||||
const registration = Object.freeze({ kind: normalizedKind, handler: normalizedHandler });
|
||||
runtimeApi.providerHandlers.set(id, registration);
|
||||
return Object.freeze({
|
||||
dispose() {
|
||||
if (runtimeApi.providerHandlers.get(id) === registration) {
|
||||
runtimeApi.providerHandlers.delete(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const terminals = {
|
||||
onDidChange: runtimeApi.terminalEvents.event,
|
||||
};
|
||||
const environment = {
|
||||
get locale() { return runtimeApi.environment.locale ?? "en"; },
|
||||
get theme() { return runtimeApi.environment.theme ?? "system"; },
|
||||
get reducedMotion() { return runtimeApi.environment.reducedMotion === true; },
|
||||
get highContrast() { return runtimeApi.environment.highContrast === true; },
|
||||
get themeTokens() { return runtimeApi.environment.themeTokens; },
|
||||
onDidChange: runtimeApi.environmentChanged.event,
|
||||
};
|
||||
const logger = Object.fromEntries(["debug", "info", "warn", "error"].map((level) => [
|
||||
level,
|
||||
(message, fields) => client.notify("log.write", {
|
||||
level,
|
||||
message: String(message).slice(0, 2_048),
|
||||
...(fields === undefined ? {} : { fields }),
|
||||
}),
|
||||
]));
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
netcattyVersion: config.netcattyVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: new Set(config.enabledFeatures),
|
||||
subscriptions,
|
||||
storage,
|
||||
settings,
|
||||
commands,
|
||||
contextKeys,
|
||||
views,
|
||||
providers,
|
||||
terminals,
|
||||
environment,
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
filesystem,
|
||||
companions,
|
||||
streams,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPluginRuntime({
|
||||
port,
|
||||
config,
|
||||
loadPlugin,
|
||||
loadTerminalInterceptorTransport: loadTerminalInterceptorTransportForRuntime = loadTerminalInterceptorTransport,
|
||||
}) {
|
||||
const transport = createTransportAdapter(port);
|
||||
const client = createHostClient(transport);
|
||||
const cancellation = new Map();
|
||||
let plugin;
|
||||
let context;
|
||||
let activated = false;
|
||||
let deactivated = false;
|
||||
const runtimeApi = {
|
||||
commandHandlers: new Map(),
|
||||
providerHandlers: new Map(),
|
||||
settingsChanged: createEmitter(),
|
||||
environmentChanged: createEmitter(),
|
||||
terminalEvents: createEmitter(),
|
||||
environment: normalizeRuntimeEnvironment(config.environment),
|
||||
viewMessages: new Map(),
|
||||
terminalInterceptorPorts: new Set(),
|
||||
streams: null,
|
||||
};
|
||||
runtimeApi.streams = createPluginStreamEndpoint(transport);
|
||||
const pluginModule = await loadPlugin(config.entryUrl);
|
||||
plugin = pluginModule?.default;
|
||||
if (!plugin || typeof plugin.activate !== "function") {
|
||||
throw new Error("Plugin entrypoint must default-export a plugin with activate(context)");
|
||||
}
|
||||
|
||||
async function handleRequest(message, cancellationToken, ports = []) {
|
||||
const isTerminalAttachment = message.method === "plugin.terminal.interceptor.attach";
|
||||
if (!isTerminalAttachment) closeTransferredPorts(ports);
|
||||
if (message.method === "plugin.initialize") {
|
||||
if (context) throw new PluginError("failed_precondition", "Plugin is already initialized");
|
||||
context = createPluginContext(config, client, runtimeApi);
|
||||
return {
|
||||
pluginId: config.pluginId,
|
||||
pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion,
|
||||
enabledFeatures: [...config.enabledFeatures],
|
||||
};
|
||||
}
|
||||
if (message.method === "plugin.activate") {
|
||||
if (!context) throw new PluginError("failed_precondition", "Plugin must be initialized first");
|
||||
if (!activated) {
|
||||
if (message.params?.environment && typeof message.params.environment === "object"
|
||||
&& !Array.isArray(message.params.environment)) {
|
||||
runtimeApi.environment = normalizeRuntimeEnvironment(message.params.environment);
|
||||
}
|
||||
const disposable = await plugin.activate(context);
|
||||
if (disposable && typeof disposable.dispose === "function") context.subscriptions.add(disposable);
|
||||
activated = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (message.method === "plugin.deactivate") {
|
||||
if (!deactivated) {
|
||||
deactivated = true;
|
||||
await plugin.deactivate?.();
|
||||
context?.subscriptions.dispose();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (message.method === "plugin.terminal.interceptor.attach") {
|
||||
if (ports.length !== 1) {
|
||||
closeTransferredPorts(ports);
|
||||
throw new PluginError("invalid_argument", "Terminal interceptor attachment requires exactly one port");
|
||||
}
|
||||
try {
|
||||
await attachTerminalInterceptor(message.params, ports, cancellationToken);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
closeTransferredPorts(ports);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (message.method === "plugin.command.execute") {
|
||||
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
|
||||
const command = assertOwnedContributionId(config.pluginId, message.params?.command, "Plugin command");
|
||||
const handler = runtimeApi.commandHandlers.get(command);
|
||||
if (!handler) throw new PluginError("failed_precondition", `Plugin command has no registered handler: ${command}`);
|
||||
return await handler(message.params?.args, message.params?.invocation);
|
||||
}
|
||||
if (message.method === "provider.invoke") {
|
||||
if (!activated || !context) throw new PluginError("failed_precondition", "Plugin is not activated");
|
||||
const providerId = assertOwnedContributionId(config.pluginId, message.params?.providerId, "Plugin Provider");
|
||||
const kind = assertProviderKind(message.params?.kind);
|
||||
const registration = runtimeApi.providerHandlers.get(providerId);
|
||||
if (!registration) {
|
||||
throw new PluginError("failed_precondition", `Plugin Provider has no registered handler: ${providerId}`);
|
||||
}
|
||||
if (registration.kind !== kind) {
|
||||
throw new PluginError("failed_precondition", `Plugin Provider kind changed: ${providerId}`);
|
||||
}
|
||||
const requestId = message.params?.requestId;
|
||||
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider request ID is invalid");
|
||||
}
|
||||
const operation = message.params?.operation;
|
||||
if (typeof operation !== "string" || operation.length < 1 || operation.length > 128) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider operation is invalid");
|
||||
}
|
||||
const deadlineMs = message.params?.deadlineMs;
|
||||
if (deadlineMs != null && (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 300_000)) {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider deadline is invalid");
|
||||
}
|
||||
const payload = message.params?.payload === undefined
|
||||
? undefined
|
||||
: freezeRuntimeJson(message.params.payload);
|
||||
const connectionOpen = kind === "connection" && operation === "open";
|
||||
const importerParse = kind === "importer" && operation === "parse";
|
||||
const syncReadStream = kind === "sync" && operation === "readObject" && typeof payload?.outputStreamId === "string";
|
||||
const syncWriteStream = kind === "sync" && operation === "writeObject" && typeof payload?.inputStreamId === "string";
|
||||
const streamed = connectionOpen || importerParse || syncReadStream || syncWriteStream;
|
||||
let input;
|
||||
let output;
|
||||
let cancelStreams;
|
||||
if (streamed) {
|
||||
const inputStreamId = payload?.inputStreamId;
|
||||
const outputStreamId = payload?.outputStreamId;
|
||||
const windowBytes = payload?.windowBytes;
|
||||
if (connectionOpen || importerParse) {
|
||||
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
|
||||
}
|
||||
} else if (syncReadStream && typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync readObject stream requires an output stream ID");
|
||||
} else if (syncWriteStream && typeof inputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync writeObject stream requires an input stream ID");
|
||||
}
|
||||
if (typeof inputStreamId === "string") {
|
||||
input = runtimeApi.streams.acceptReadable(inputStreamId);
|
||||
// Prevent a cancelled request from creating an unhandled rejected
|
||||
// promise when the Provider never awaited its input stream.
|
||||
void input.catch(() => {});
|
||||
}
|
||||
if (typeof outputStreamId === "string") {
|
||||
try {
|
||||
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
|
||||
} catch (error) {
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
cancelStreams = () => {
|
||||
const error = new PluginError("cancelled", "Streamed Provider invocation was cancelled");
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
void input?.then((stream) => stream.cancel(), () => {});
|
||||
}
|
||||
output?.cancel?.();
|
||||
};
|
||||
}
|
||||
const cancellationDisposable = cancelStreams
|
||||
? cancellationToken.onCancellationRequested(cancelStreams)
|
||||
: null;
|
||||
const invocation = Object.freeze({
|
||||
providerId,
|
||||
kind,
|
||||
operation,
|
||||
requestId,
|
||||
payload,
|
||||
deadlineMs,
|
||||
cancellationToken,
|
||||
...(input !== undefined ? { input } : {}),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
});
|
||||
try {
|
||||
const handler = OPERATION_MAP_PROVIDER_KINDS.has(registration.kind)
|
||||
? registration.handler[operation]
|
||||
: registration.handler;
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", `${kind} Provider operation is not implemented: ${operation}`);
|
||||
}
|
||||
const result = await handler(invocation);
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
// Sync readObject may return inline base64 or found:false while the host
|
||||
// still opened an output stream for large-object fallback — release it.
|
||||
if (syncReadStream && output && !(result && result.streamed === true)) {
|
||||
try { output.cancel?.(); } catch { /* best-effort */ }
|
||||
}
|
||||
return { requestId, status: "ok", result: result === undefined ? null : result };
|
||||
} catch (error) {
|
||||
cancelStreams?.();
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
const rpcError = normalizeError(error);
|
||||
return {
|
||||
requestId,
|
||||
status: "failed",
|
||||
error: { code: rpcError.code, message: rpcError.message, ...(rpcError.data === undefined ? {} : { data: rpcError.data }) },
|
||||
};
|
||||
} finally {
|
||||
cancellationDisposable?.dispose();
|
||||
if (streamed && cancellationToken.isCancellationRequested) cancelStreams();
|
||||
}
|
||||
}
|
||||
throw new PluginError("unsupported", `Unsupported host method: ${message.method}`);
|
||||
}
|
||||
|
||||
function handleNotification(message) {
|
||||
if (message.method === "plugin.settings.changed") {
|
||||
runtimeApi.settingsChanged.fire(message.params);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.environment.changed") {
|
||||
runtimeApi.environment = normalizeRuntimeEnvironment(message.params);
|
||||
runtimeApi.environmentChanged.fire(runtimeApi.environment);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.view.message") {
|
||||
const viewId = assertOwnedContributionId(config.pluginId, message.params?.viewId, "Plugin view");
|
||||
runtimeApi.viewMessages.get(viewId)?.fire(message.params?.message);
|
||||
return true;
|
||||
}
|
||||
if (message.method === "plugin.terminal.event") {
|
||||
runtimeApi.terminalEvents.fire(freezeRuntimeJson(message.params));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachTerminalInterceptor = async (message, ports, cancellationToken) => {
|
||||
const descriptor = message?.descriptor;
|
||||
const providerId = assertOwnedContributionId(config.pluginId, descriptor?.providerId, "Terminal interceptor");
|
||||
const direction = descriptor?.direction;
|
||||
const kind = direction === "input" ? "terminal.interceptor.input"
|
||||
: direction === "output" ? "terminal.interceptor.output" : null;
|
||||
const sessionId = descriptor?.session?.sessionId;
|
||||
if (!kind || typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256) {
|
||||
throw new PluginError("invalid_argument", "Terminal interceptor descriptor is invalid");
|
||||
}
|
||||
const registration = runtimeApi.providerHandlers.get(providerId);
|
||||
const interceptorPort = ports?.[0];
|
||||
if (!activated || registration?.kind !== kind || !interceptorPort?.postMessage) {
|
||||
interceptorPort?.close?.();
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor provider is not registered");
|
||||
}
|
||||
runtimeApi.terminalInterceptorPorts.add(interceptorPort);
|
||||
let attached = false;
|
||||
try {
|
||||
const {
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = await loadTerminalInterceptorTransportForRuntime();
|
||||
if (cancellationToken?.isCancellationRequested
|
||||
|| deactivated
|
||||
|| !activated
|
||||
|| !runtimeApi.terminalInterceptorPorts.has(interceptorPort)
|
||||
|| runtimeApi.providerHandlers.get(providerId) !== registration) {
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor attachment was cancelled or became stale");
|
||||
}
|
||||
const handleChunk = (event) => {
|
||||
let envelope;
|
||||
try {
|
||||
const message = messageData(event);
|
||||
envelope = createTerminalInterceptorEnvelope(message?.frame, message?.transfer);
|
||||
} catch {
|
||||
interceptorPort.close?.();
|
||||
return;
|
||||
}
|
||||
const chunk = envelope.frame;
|
||||
if (chunk?.type === "netcatty:terminal-interceptor:ready") return;
|
||||
if (chunk.type !== "netcatty:terminal-interceptor:chunk"
|
||||
|| chunk.direction !== direction) {
|
||||
interceptorPort.close?.();
|
||||
return;
|
||||
}
|
||||
const invocation = Object.freeze({
|
||||
providerId,
|
||||
kind,
|
||||
direction,
|
||||
sequence: chunk.sequence,
|
||||
session: freezeRuntimeJson(descriptor.session),
|
||||
data: new Uint8Array(envelope.transfer),
|
||||
});
|
||||
// Resolve the registration for every chunk so disposal/re-registration
|
||||
// cannot keep a captured stale handler alive. Starting from a resolved
|
||||
// promise also converts synchronous handler throws into the ordinary
|
||||
// failed response path instead of tearing down the utility runtime.
|
||||
void Promise.resolve().then(() => {
|
||||
const currentRegistration = runtimeApi.providerHandlers.get(providerId);
|
||||
if (currentRegistration?.kind !== kind) {
|
||||
throw new PluginError("failed_precondition", "Terminal interceptor provider is no longer registered");
|
||||
}
|
||||
return currentRegistration.handler(invocation);
|
||||
}).then(
|
||||
(result) => {
|
||||
let bytes;
|
||||
if (result instanceof Uint8Array) bytes = result;
|
||||
else if (result instanceof ArrayBuffer) bytes = new Uint8Array(result);
|
||||
else throw new PluginError("data_loss", "Terminal interceptor must return Uint8Array or ArrayBuffer");
|
||||
if (bytes.byteLength > TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES) {
|
||||
throw new PluginError("resource_exhausted", "Terminal interceptor result is too large");
|
||||
}
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
const resultEnvelope = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: chunk.sequence,
|
||||
status: "ok",
|
||||
creditBytes: chunk.byteLength,
|
||||
byteLength: copy.byteLength,
|
||||
}, copy.buffer);
|
||||
interceptorPort.postMessage(resultEnvelope, [copy.buffer]);
|
||||
},
|
||||
() => interceptorPort.postMessage(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: chunk.sequence,
|
||||
status: "failed",
|
||||
})),
|
||||
).catch(() => interceptorPort.close?.());
|
||||
};
|
||||
if (typeof interceptorPort.addEventListener === "function") {
|
||||
interceptorPort.addEventListener("message", handleChunk);
|
||||
} else {
|
||||
interceptorPort.on("message", handleChunk);
|
||||
}
|
||||
interceptorPort.start?.();
|
||||
interceptorPort.on?.("close", () => runtimeApi.terminalInterceptorPorts.delete(interceptorPort));
|
||||
attached = true;
|
||||
} finally {
|
||||
if (!attached) {
|
||||
runtimeApi.terminalInterceptorPorts.delete(interceptorPort);
|
||||
interceptorPort.close?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = transport.onMessage((message, ports) => {
|
||||
if (message && typeof message === "object" && Object.hasOwn(message, "frame")) {
|
||||
for (const port of ports) port?.close?.();
|
||||
try {
|
||||
if (!runtimeApi.streams.accept(message)) cancelUnhandledStream(transport, message);
|
||||
}
|
||||
catch { transport.close(); }
|
||||
return;
|
||||
}
|
||||
if (client.accept(message)) {
|
||||
for (const port of ports) port?.close?.();
|
||||
return;
|
||||
}
|
||||
if (!message || message.jsonrpc !== "2.0") {
|
||||
for (const port of ports) port?.close?.();
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/cancelRequest") {
|
||||
for (const port of ports) port?.close?.();
|
||||
cancellation.get(message.params?.cancellationId)?.cancel();
|
||||
return;
|
||||
}
|
||||
if (!Object.hasOwn(message, "id")) {
|
||||
for (const port of ports) port?.close?.();
|
||||
try { handleNotification(message); } catch { transport.close(); }
|
||||
return;
|
||||
}
|
||||
const cancellationId = message.cancellationId;
|
||||
const source = new CancellationTokenSource();
|
||||
if (cancellationId) cancellation.set(cancellationId, source);
|
||||
void handleRequest(message, source.token, ports).then(
|
||||
(result) => transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: result === undefined ? null : result,
|
||||
}),
|
||||
(error) => {
|
||||
const rpcError = normalizeError(error);
|
||||
transport.post(makeRpcFailure(message.id, rpcError.code, rpcError.message, rpcError.data));
|
||||
},
|
||||
).finally(() => {
|
||||
if (cancellationId) cancellation.delete(cancellationId);
|
||||
source.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
dispose();
|
||||
client.close();
|
||||
for (const source of cancellation.values()) source.cancel();
|
||||
runtimeApi.commandHandlers.clear();
|
||||
runtimeApi.providerHandlers.clear();
|
||||
runtimeApi.settingsChanged.clear();
|
||||
runtimeApi.environmentChanged.clear();
|
||||
runtimeApi.terminalEvents.clear();
|
||||
for (const interceptorPort of runtimeApi.terminalInterceptorPorts) interceptorPort.close?.();
|
||||
runtimeApi.terminalInterceptorPorts.clear();
|
||||
runtimeApi.streams.close();
|
||||
for (const emitter of runtimeApi.viewMessages.values()) emitter.clear();
|
||||
cancellation.clear();
|
||||
if (!deactivated) {
|
||||
await plugin.deactivate?.();
|
||||
context?.subscriptions.dispose();
|
||||
}
|
||||
transport.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user