[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@netcatty/plugin-sdk",
"version": "0.1.0-internal",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json"
},
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal"
}
}

View File

@@ -0,0 +1,397 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import * as ts from "typescript";
import {
CancellationError,
CancellationTokenSource,
definePlugin,
DisposableStore,
PluginError,
PLUGIN_ERROR_WIRE_CODES,
pluginErrorToRpcError,
throwIfCancellationRequested,
} from "./index.ts";
import type { PluginSecretStore, SecretRef } from "./index.ts";
const testSecretRef: SecretRef = {
kind: "secret",
id: "secret-reference-1",
key: "token",
};
const testSecretStore: PluginSecretStore = {
async get() {
return testSecretRef;
},
async set() {
return testSecretRef;
},
async delete() {},
};
function assertSdkTypeChecks(source: string) {
const sdkDirectory = dirname(fileURLToPath(import.meta.url));
const fixturePath = join(sdkDirectory, "__provider-overload-fixture.ts");
const compilerOptions: ts.CompilerOptions = {
allowImportingTsExtensions: true,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ES2022,
};
const host = ts.createCompilerHost(compilerOptions, true);
const fileExists = host.fileExists.bind(host);
const readCompilerFile = host.readFile.bind(host);
host.fileExists = (fileName) => fileName === fixturePath || fileExists(fileName);
host.readFile = (fileName) => fileName === fixturePath ? source : readCompilerFile(fileName);
const program = ts.createProgram([fixturePath], compilerOptions, host);
const diagnostics = ts.getPreEmitDiagnostics(program)
.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
assert.deepEqual(
diagnostics.map((diagnostic) => {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
if (!diagnostic.file || diagnostic.start === undefined) {
return `TS${diagnostic.code}: ${message}`;
}
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} TS${diagnostic.code}: ${message}`;
}),
[],
);
}
test("PluginError maps stable SDK codes to stable JSON-RPC wire errors", () => {
const error = new PluginError("permission_denied", "Approval required", { scope: "terminal" });
assert.deepEqual(pluginErrorToRpcError(error), {
code: -32007,
message: "Approval required",
data: {
pluginCode: "permission_denied",
details: { scope: "terminal" },
},
});
assert.equal(PLUGIN_ERROR_WIRE_CODES.cancelled, -32001);
assert.equal(PLUGIN_ERROR_WIRE_CODES.internal, -32013);
assert.equal(new Set(Object.values(PLUGIN_ERROR_WIRE_CODES)).size, 16);
for (const code of Object.keys(PLUGIN_ERROR_WIRE_CODES)) {
const mapped = pluginErrorToRpcError(new PluginError(
code as keyof typeof PLUGIN_ERROR_WIRE_CODES,
code,
));
assert.equal(mapped.code, PLUGIN_ERROR_WIRE_CODES[code as keyof typeof PLUGIN_ERROR_WIRE_CODES]);
assert.deepEqual(mapped.data, { pluginCode: code });
}
});
test("PluginError wire mapping covers the exact contract schema enums", async () => {
const schema = JSON.parse(await readFile(
new URL("../../plugin-contract/schema/plugin-contract.schema.json", import.meta.url),
"utf8",
));
assert.deepEqual(
Object.keys(PLUGIN_ERROR_WIRE_CODES).sort(),
[...schema.$defs.PluginErrorName.enum].sort(),
);
assert.deepEqual(
Object.values(PLUGIN_ERROR_WIRE_CODES).sort((left, right) => left - right),
[...schema.$defs.PluginWireErrorCode.enum].sort((left, right) => left - right),
);
});
test("definePlugin preserves the exact plugin object", () => {
const plugin = definePlugin({ activate() {} });
assert.equal(typeof plugin.activate, "function");
});
test("PluginSecretStore exposes opaque references instead of plaintext reads", async () => {
assert.deepEqual(await testSecretStore.get("token"), testSecretRef);
assert.deepEqual(await testSecretStore.set("token", "already-known-value"), testSecretRef);
assert.equal("value" in testSecretRef, false);
assert.equal(testSecretRef.key, "token");
});
test("terminal interceptor typing stays specialized while broad ProviderKind helpers remain compatible", async () => {
const source = await readFile(new URL("./index.ts", import.meta.url), "utf8");
assert.match(
source,
/kind: Exclude<\s*ProviderKind,\s*TerminalInterceptorKind \| OrdinaryTerminalProviderKind \| "connection" \| "authentication" \| "importer" \| "sync"\s*>,\s*handler: PluginProviderHandler/u,
);
assert.match(
source,
/type ProviderHandlerForKind<[\s\S]*K extends TerminalInterceptorKind[\s\S]*TerminalInterceptorHandler/u,
);
assert.match(
source,
/kind: K,\s*handler: ProviderHandlerForKind<NoInfer<K>, TPayload, TResult>/u,
);
});
test("provider registrations infer typed connection importer and sync stream invocations", () => {
assertSdkTypeChecks(`
import { definePlugin } from "./index.ts";
import type {
ConnectionProviderHandler,
ConnectionProviderResultByOperation,
AuthenticationResult,
ImporterKeyDraft,
ImporterProviderHandler,
SyncProviderHandler,
SyncProviderResultByOperation,
} from "./index.ts";
const resizeAck: ConnectionProviderResultByOperation["resize"] = null;
void resizeAck;
// @ts-expect-error connection control operations acknowledge with JSON null, never object payloads.
const invalidResizeAck: ConnectionProviderResultByOperation["resize"] = { ok: true };
void invalidResizeAck;
const invalidConnectionProvider: ConnectionProviderHandler = {
validateConfiguration: () => ({ valid: true, issues: [] }),
probe: () => ({ available: true }),
open: () => ({ connectionId: "connection-1", status: "connected" }),
// @ts-expect-error resize must return the resize control acknowledgement, not a probe result.
resize: () => ({ available: true }),
signal: () => null,
reconnect: () => null,
close: () => null,
getStatus: () => ({ status: "connected" }),
};
void invalidConnectionProvider;
const inlineImporterKey: ImporterKeyDraft = {
label: "Inline key",
type: "ED25519",
privateKey: "private",
};
const fileImporterKey: ImporterKeyDraft = {
label: "File key",
type: "ED25519",
filePath: "/keys/id_ed25519",
};
void inlineImporterKey;
void fileImporterKey;
// @ts-expect-error runtime validation requires exactly one key source.
const ambiguousImporterKey: ImporterKeyDraft = {
label: "Ambiguous key",
type: "ED25519",
privateKey: "private",
filePath: "/keys/id_ed25519",
};
void ambiguousImporterKey;
const invalidImporterProvider: ImporterProviderHandler = {
// @ts-expect-error detect must return a detection result, not parse counters.
detect: () => ({ parsed: 0, warnings: 0, errors: 0 }),
parse: () => ({ parsed: 0, warnings: 0, errors: 0 }),
};
void invalidImporterProvider;
const disconnectAck: SyncProviderResultByOperation["disconnect"] = null;
void disconnectAck;
// @ts-expect-error disconnect acknowledges with JSON null.
const invalidDisconnectAck: SyncProviderResultByOperation["disconnect"] = { ok: true };
void invalidDisconnectAck;
const invalidSyncProvider: SyncProviderHandler = {
connect: () => ({ account: { id: "a" } }),
disconnect: () => null,
getAccount: () => ({ account: null }),
getCapabilities: () => ({ revisions: true, conditionalWrites: true, atomicReplacement: true }),
// @ts-expect-error readObject must return a SyncReadObjectResult, not write result.
readObject: () => ({ created: true }),
writeObject: () => ({ created: true }),
deleteObject: () => ({ deleted: true }),
};
void invalidSyncProvider;
// @ts-expect-error challenge results must include the exact challenge payload.
const incompleteAuthenticationResult: AuthenticationResult = { status: "challenge" };
void incompleteAuthenticationResult;
definePlugin({
activate(context) {
context.providers.register("com.example.connection", "connection", {
async open(invocation) {
const input = await invocation.input;
const chunk: Uint8Array | null = await input.read();
if (chunk) {
await invocation.output.write(chunk);
}
await invocation.output.end();
return { connectionId: "connection-1", status: "connected" };
},
validateConfiguration(invocation) {
const configuration = invocation.payload.configuration;
void configuration;
return { valid: true, issues: [] };
},
probe() {
return { available: true };
},
resize() {
return null;
},
signal() {
return null;
},
reconnect() {
return null;
},
close() {
return null;
},
getStatus() {
return {
status: "connected",
diagnostics: [{ severity: "warning", message: "using fallback host key algorithm" }],
};
},
});
// @ts-expect-error connection Providers use operation-keyed handlers so each operation has its exact result.
context.providers.register("com.example.connection.invalid", "connection", async () => ({ available: true }));
context.providers.register("com.example.importer", "importer", {
async parse(invocation) {
const input = await invocation.input;
await invocation.output.write(new Uint8Array([65]));
await input.read();
return { parsed: 0, warnings: 0, errors: 0 };
},
detect(invocation) {
const sampleData: string = invocation.payload.sample.data;
void sampleData;
return { confidence: 1 };
},
});
context.providers.register("com.example.sync", "sync", {
connect(invocation) {
void invocation.payload.configuration;
return { account: { id: "acct" } };
},
disconnect() {
return null;
},
getAccount() {
return { account: { id: "acct" } };
},
getCapabilities() {
return {
revisions: true,
conditionalWrites: true,
atomicReplacement: true,
maxObjectBytes: 1024,
};
},
async readObject(invocation) {
if (invocation.output) {
await invocation.output.write(new Uint8Array([1, 2, 3]));
await invocation.output.end();
return { found: true, byteLength: 3, streamed: true, revision: "r1" };
}
return {
found: true,
byteLength: 3,
encoding: "base64",
data: "AQID",
revision: "r1",
};
},
async writeObject(invocation) {
if (invocation.input) {
const stream = await invocation.input;
await stream.read();
}
return { created: true, revision: "r2" };
},
deleteObject() {
return { deleted: true };
},
});
// @ts-expect-error sync Providers use operation-keyed handlers.
context.providers.register("com.example.sync.invalid", "sync", async () => ({ account: { id: "x" } }));
},
});
`);
});
test("DisposableStore disposes every item once", () => {
const store = new DisposableStore();
const calls: string[] = [];
store.add({ dispose: () => calls.push("first") });
store.add({ dispose: () => calls.push("second") });
store.dispose();
store.dispose();
assert.deepEqual(calls, ["first", "second"]);
});
test("DisposableStore disposes rejected late additions", () => {
const store = new DisposableStore();
store.dispose();
let disposed = false;
assert.throws(
() => store.add({ dispose: () => { disposed = true; } }),
(error) => error instanceof PluginError && error.code === "unavailable",
);
assert.equal(disposed, true);
});
test("CancellationTokenSource notifies listeners once", () => {
const source = new CancellationTokenSource();
let count = 0;
source.token.onCancellationRequested(() => count += 1);
source.cancel();
source.cancel();
assert.equal(count, 1);
assert.equal(source.token.isCancellationRequested, true);
assert.throws(
() => throwIfCancellationRequested(source.token),
CancellationError,
);
});
test("CancellationTokenSource notifies every listener before reporting failures", () => {
const source = new CancellationTokenSource();
const calls: string[] = [];
source.token.onCancellationRequested(() => {
calls.push("failing");
throw new Error("listener failed");
});
source.token.onCancellationRequested(() => calls.push("surviving"));
assert.throws(
() => source.cancel(),
(error) => error instanceof AggregateError
&& error.errors.length === 1
&& error.errors[0] instanceof Error
&& error.errors[0].message === "listener failed",
);
assert.deepEqual(calls, ["failing", "surviving"]);
assert.equal(source.token.isCancellationRequested, true);
assert.doesNotThrow(() => source.cancel());
});
test("CancellationTokenSource finishes disposal when a cancellation listener fails", () => {
const source = new CancellationTokenSource();
source.token.onCancellationRequested(() => {
throw new Error("listener failed");
});
assert.throws(() => source.dispose(true), AggregateError);
assert.doesNotThrow(() => source.dispose(true));
});

View File

@@ -0,0 +1,924 @@
import type {
AuthenticationBeginPayload,
AuthenticationResponsePayload,
AuthenticationResult,
ConnectionConfigurationPayload,
ConnectionControlResult,
ConnectionControlPayload,
ConnectionOpenPayload,
ConnectionOpenResult,
ConnectionProbeResult,
ConnectionResizePayload,
ConnectionSignalPayload,
ConnectionStatusResult,
ConnectionValidateResult,
CredentialRef,
FeatureId,
ImporterDetectPayload,
ImporterDetectResult,
ImporterParsePayload,
ImporterParseResult,
JsonValue,
PluginErrorData,
PluginErrorName,
PluginId,
ProviderKind,
PluginWireErrorCode,
RpcErrorObject,
SecretLeaseRef,
SecretRef,
SemanticVersion,
SyncCapabilitiesResult,
SyncConnectPayload,
SyncConnectResult,
SyncDeleteObjectPayload,
SyncDeleteObjectResult,
SyncDisconnectPayload,
SyncDisconnectResult,
SyncGetAccountPayload,
SyncGetAccountResult,
SyncGetCapabilitiesPayload,
SyncReadObjectPayload,
SyncReadObjectResult,
SyncWriteObjectPayload,
SyncWriteObjectResult,
TerminalSessionSnapshot,
} from "@netcatty/plugin-contract";
export type * from "@netcatty/plugin-contract";
export interface Disposable {
dispose(): void;
}
export type CancellationListener = () => void;
export interface CancellationToken {
readonly isCancellationRequested: boolean;
onCancellationRequested(listener: CancellationListener): Disposable;
}
export interface PluginLogger {
debug(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
info(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
warn(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
error(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
}
export interface PluginKeyValueStore {
get<T extends JsonValue>(key: string): Promise<T | undefined>;
set(key: string, value: JsonValue): Promise<void>;
delete(key: string): Promise<void>;
keys(): Promise<readonly string[]>;
}
export interface PluginSecretStore {
get(key: string): Promise<SecretRef | undefined>;
set(key: string, value: string): Promise<SecretRef>;
delete(key: string): Promise<void>;
}
export interface PluginSettingOptions {
readonly scopeId?: string;
}
export interface PluginSettingChangeEvent {
readonly settingId: string;
readonly scope: string;
readonly scopeId: string;
readonly source: "host" | "plugin";
}
export interface PluginSettings {
get<T extends JsonValue | SecretRef>(settingId: string, options?: PluginSettingOptions): Promise<T | undefined>;
update(settingId: string, value: JsonValue, options?: PluginSettingOptions): Promise<Readonly<{ restartRequired: boolean }>>;
onDidChange(listener: (event: PluginSettingChangeEvent) => void): Disposable;
}
export interface PluginCommandInvocation {
readonly source: "host" | "plugin" | string;
readonly context?: Readonly<Record<string, JsonValue>>;
}
export type PluginCommandHandler = (args: JsonValue | undefined, invocation: PluginCommandInvocation) => JsonValue | void | Promise<JsonValue | void>;
export interface PluginCommands {
registerCommand(commandId: string, handler: PluginCommandHandler): Disposable;
executeCommand<T extends JsonValue = JsonValue>(commandId: string, args?: JsonValue): Promise<T>;
}
export interface PluginContextKeys {
set(key: string, value: JsonValue): Promise<void>;
}
export interface PluginViews {
onDidReceiveMessage(viewId: string, listener: (message: JsonValue) => void): Disposable;
postMessage(viewId: string, message: JsonValue): void;
getState<T extends JsonValue = JsonValue>(viewId: string, scopeId: string): Promise<T | undefined>;
setState(viewId: string, scopeId: string, state: JsonValue): Promise<void>;
}
export interface PluginProviderInvocation<TPayload extends JsonValue = JsonValue> {
readonly providerId: string;
readonly kind: ProviderKind;
readonly operation: string;
readonly requestId: string;
readonly payload: TPayload | undefined;
readonly deadlineMs: number | undefined;
readonly cancellationToken: CancellationToken;
}
export type PluginProviderHandler<
TPayload extends JsonValue = JsonValue,
TResult extends JsonValue = JsonValue,
> = (invocation: PluginProviderInvocation<TPayload>) => TResult | void | Promise<TResult | void>;
type TypedPluginProviderInvocation<TPayload> = Omit<PluginProviderInvocation, "payload"> & {
readonly payload: TPayload;
};
type ProviderHandlerForKind<
K extends ProviderKind,
TPayload extends JsonValue,
TResult extends JsonValue,
> = K extends TerminalInterceptorKind
? TerminalInterceptorHandler
: K extends OrdinaryTerminalProviderKind
? OrdinaryTerminalProviderHandler<K>
: K extends "connection"
? ConnectionProviderHandler
: K extends "authentication"
? AuthenticationProviderHandler
: K extends "importer"
? ImporterProviderHandler
: K extends "sync"
? SyncProviderHandler
: PluginProviderHandler<TPayload, TResult>;
export interface PluginProviders {
register<K extends OrdinaryTerminalProviderKind>(
providerId: string,
kind: K,
handler: OrdinaryTerminalProviderHandler<K>,
): Disposable;
register(
providerId: string,
kind: TerminalInterceptorKind,
handler: TerminalInterceptorHandler,
): Disposable;
register(
providerId: string,
kind: "connection",
handler: ConnectionProviderHandler,
): Disposable;
register(
providerId: string,
kind: "authentication",
handler: AuthenticationProviderHandler,
): Disposable;
register(
providerId: string,
kind: "importer",
handler: ImporterProviderHandler,
): Disposable;
register(
providerId: string,
kind: "sync",
handler: SyncProviderHandler,
): Disposable;
register<TPayload extends JsonValue = JsonValue, TResult extends JsonValue = JsonValue>(
providerId: string,
kind: Exclude<
ProviderKind,
TerminalInterceptorKind | OrdinaryTerminalProviderKind | "connection" | "authentication" | "importer" | "sync"
>,
handler: PluginProviderHandler<TPayload, TResult>,
): Disposable;
register<
K extends ProviderKind,
TPayload extends JsonValue = JsonValue,
TResult extends JsonValue = JsonValue,
>(
providerId: string,
kind: K,
handler: ProviderHandlerForKind<NoInfer<K>, TPayload, TResult>,
): Disposable;
}
export type TerminalInterceptorKind = "terminal.interceptor.input" | "terminal.interceptor.output";
export interface TerminalInterceptorInvocation {
readonly providerId: string;
readonly kind: TerminalInterceptorKind;
readonly direction: "input" | "output";
readonly sequence: number;
readonly session: TerminalSessionSnapshot;
/** UTF-8 terminal data. The buffer is owned by this invocation. */
readonly data: Uint8Array;
}
export type TerminalInterceptorHandler = (
invocation: TerminalInterceptorInvocation,
) => Uint8Array | ArrayBuffer | Promise<Uint8Array | ArrayBuffer>;
export interface TerminalSessionEvent {
readonly type:
| "snapshot"
| "created"
| "connected"
| "reconnected"
| "cwdChanged"
| "titleChanged"
| "resized"
| "alternateScreenChanged"
| "commandSubmitted"
| "commandCompleted"
| "disconnected"
| "disposed";
readonly session: TerminalSessionSnapshot;
readonly exitCode?: number;
}
export interface TerminalProviderPayload {
/** Immutable host snapshot bound to this exact invocation. */
readonly session: TerminalSessionSnapshot;
}
export interface TerminalCompletionPayload extends TerminalProviderPayload {
readonly input: string;
readonly cursor: number;
readonly hostOs: "linux" | "windows" | "macos";
readonly cwdSource: "prompt" | "fallback" | "none" | null;
readonly maximum: number;
}
export interface TerminalCompletionItem {
readonly text: string;
/** When supplied, it must equal text; the host always displays the inserted command. */
readonly displayText?: string;
readonly description?: string;
readonly score?: number;
}
export interface TerminalCompletionResult {
readonly items: readonly TerminalCompletionItem[];
}
export interface TerminalDecorationPayload extends TerminalProviderPayload {
readonly reason: string;
}
export interface TerminalDecorationRule {
readonly id: string;
readonly label: string;
readonly patterns: readonly string[];
readonly color: string;
}
export interface TerminalDecorationResult {
readonly rules: readonly TerminalDecorationRule[];
}
export interface TerminalTextRange {
readonly start: number;
readonly length: number;
}
export interface TerminalLinkItem extends TerminalTextRange {
readonly uri: string;
readonly label?: string;
}
export interface TerminalLineProviderPayload extends TerminalProviderPayload {
readonly line: string;
readonly bufferLineNumber: number;
}
export interface TerminalLinkResult {
readonly links: readonly TerminalLinkItem[];
}
export interface TerminalHoverItem extends TerminalTextRange {
readonly contents: string;
}
export interface TerminalHoverResult {
readonly hovers: readonly TerminalHoverItem[];
}
export interface TerminalMatcherLine {
readonly lineId: string;
readonly line: string;
readonly bufferLineNumber: number;
}
export interface TerminalMatcherPayload extends TerminalProviderPayload {
readonly lines: readonly TerminalMatcherLine[];
}
export interface TerminalOutputMatchItem extends TerminalTextRange {
/** Host-provided line identifier from the provideMatches request batch. */
readonly lineId: string;
readonly label: string;
readonly severity?: "info" | "warning" | "error" | "success";
readonly color?: string;
}
export interface TerminalMatcherResult {
readonly matches: readonly TerminalOutputMatchItem[];
}
export interface TerminalAnnotationItem {
readonly text: string;
readonly color?: string;
}
export interface TerminalSemanticResult {
readonly classification?: string;
readonly description?: string;
readonly destructive?: boolean;
readonly idempotent?: boolean;
readonly annotations?: readonly TerminalAnnotationItem[];
}
export interface TerminalSemanticPayload extends TerminalProviderPayload {
readonly command: string;
}
export interface TerminalPromptPayload extends TerminalProviderPayload {
readonly reason: "commandCompleted";
readonly promptLine?: string;
readonly bufferLineNumber?: number;
}
export interface TerminalPromptResult {
readonly annotations: readonly TerminalAnnotationItem[];
}
export interface TerminalBackgroundLayer {
readonly id: string;
readonly color: string;
/** Defaults to a host-owned safe opacity of 0.15. */
readonly opacity?: number;
}
export interface TerminalBackgroundResult {
readonly layers: readonly TerminalBackgroundLayer[];
/** Optional bounded host refresh cadence. The host clamps this to 250-60000 ms. */
readonly refreshAfterMs?: number;
}
export interface TerminalBackgroundPayload extends TerminalProviderPayload {
readonly reason: string;
readonly terminalBackground?: string;
}
export type TerminalThemeColorName =
| "background" | "foreground" | "cursor" | "selection"
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white"
| "brightBlack" | "brightRed" | "brightGreen" | "brightYellow"
| "brightBlue" | "brightMagenta" | "brightCyan" | "brightWhite";
export interface TerminalThemePayload extends TerminalProviderPayload {
readonly reason: string;
readonly currentTheme: {
readonly type: "dark" | "light";
readonly colors: Readonly<Record<TerminalThemeColorName, string>>;
};
}
export interface TerminalThemeResult {
readonly colors: Readonly<Partial<Record<TerminalThemeColorName, string>>>;
}
export interface OrdinaryTerminalProviderPayloadByKind {
readonly "terminal.completion": TerminalCompletionPayload;
readonly "terminal.decoration": TerminalDecorationPayload;
readonly "terminal.link": TerminalLineProviderPayload;
readonly "terminal.hover": TerminalLineProviderPayload;
readonly "terminal.matcher": TerminalMatcherPayload;
readonly "terminal.semantic": TerminalSemanticPayload;
readonly "terminal.prompt": TerminalPromptPayload;
readonly "terminal.background": TerminalBackgroundPayload;
readonly "terminal.theme": TerminalThemePayload;
}
export interface OrdinaryTerminalProviderResultByKind {
readonly "terminal.completion": TerminalCompletionResult;
readonly "terminal.decoration": TerminalDecorationResult;
readonly "terminal.link": TerminalLinkResult;
readonly "terminal.hover": TerminalHoverResult;
readonly "terminal.matcher": TerminalMatcherResult;
readonly "terminal.semantic": TerminalSemanticResult;
readonly "terminal.prompt": TerminalPromptResult;
readonly "terminal.background": TerminalBackgroundResult;
readonly "terminal.theme": TerminalThemeResult;
}
export interface OrdinaryTerminalProviderOperationByKind {
readonly "terminal.completion": "provideCompletions";
readonly "terminal.decoration": "provideDecorations";
readonly "terminal.link": "provideLinks";
readonly "terminal.hover": "provideHovers";
readonly "terminal.matcher": "provideMatches";
readonly "terminal.semantic": "provideSemantics";
readonly "terminal.prompt": "provideAnnotations";
readonly "terminal.background": "provideBackgrounds";
readonly "terminal.theme": "provideTheme";
}
export type OrdinaryTerminalProviderKind = keyof OrdinaryTerminalProviderPayloadByKind;
export interface OrdinaryTerminalProviderInvocation<K extends OrdinaryTerminalProviderKind> {
readonly providerId: string;
readonly kind: K;
readonly operation: OrdinaryTerminalProviderOperationByKind[K];
readonly requestId: string;
readonly payload: OrdinaryTerminalProviderPayloadByKind[K];
readonly deadlineMs: number | undefined;
readonly cancellationToken: CancellationToken;
}
export type OrdinaryTerminalProviderHandler<K extends OrdinaryTerminalProviderKind> = (
invocation: OrdinaryTerminalProviderInvocation<K>,
) => OrdinaryTerminalProviderResultByKind[K] | Promise<OrdinaryTerminalProviderResultByKind[K]>;
export interface ConnectionProviderInvocationByOperation {
readonly validateConfiguration: TypedPluginProviderInvocation<ConnectionConfigurationPayload> & {
readonly kind: "connection";
readonly operation: "validateConfiguration";
};
readonly probe: TypedPluginProviderInvocation<ConnectionConfigurationPayload> & {
readonly kind: "connection";
readonly operation: "probe";
};
readonly open: TypedPluginProviderInvocation<ConnectionOpenPayload> & {
readonly kind: "connection";
readonly operation: "open";
readonly input: Promise<PluginReadableByteStream>;
readonly output: PluginWritableByteStream;
};
readonly resize: TypedPluginProviderInvocation<ConnectionResizePayload> & {
readonly kind: "connection";
readonly operation: "resize";
};
readonly signal: TypedPluginProviderInvocation<ConnectionSignalPayload> & {
readonly kind: "connection";
readonly operation: "signal";
};
readonly reconnect: TypedPluginProviderInvocation<ConnectionControlPayload> & {
readonly kind: "connection";
readonly operation: "reconnect";
};
readonly close: TypedPluginProviderInvocation<ConnectionControlPayload> & {
readonly kind: "connection";
readonly operation: "close";
};
readonly getStatus: TypedPluginProviderInvocation<ConnectionControlPayload> & {
readonly kind: "connection";
readonly operation: "getStatus";
};
}
export interface ConnectionProviderResultByOperation {
readonly validateConfiguration: ConnectionValidateResult;
readonly probe: ConnectionProbeResult;
readonly open: ConnectionOpenResult;
readonly resize: ConnectionControlResult;
readonly signal: ConnectionControlResult;
readonly reconnect: ConnectionControlResult;
readonly close: ConnectionControlResult;
readonly getStatus: ConnectionStatusResult;
}
export type ConnectionProviderOperation = keyof ConnectionProviderInvocationByOperation;
export type ConnectionProviderInvocation =
ConnectionProviderInvocationByOperation[ConnectionProviderOperation];
export type ConnectionProviderResult =
ConnectionProviderResultByOperation[ConnectionProviderOperation];
export type ConnectionProviderOperationHandler<TOperation extends ConnectionProviderOperation> = (
invocation: ConnectionProviderInvocationByOperation[TOperation],
) => ConnectionProviderResultByOperation[TOperation] | Promise<ConnectionProviderResultByOperation[TOperation]>;
export type ConnectionProviderHandler = Readonly<{
[TOperation in ConnectionProviderOperation]: ConnectionProviderOperationHandler<TOperation>;
}>;
export type AuthenticationProviderInvocation =
| (TypedPluginProviderInvocation<AuthenticationBeginPayload> & {
readonly kind: "authentication";
readonly operation: "begin";
})
| (TypedPluginProviderInvocation<AuthenticationResponsePayload> & {
readonly kind: "authentication";
readonly operation: "respond";
})
| (TypedPluginProviderInvocation<Readonly<{ operationId: string }>> & {
readonly kind: "authentication";
readonly operation: "cancel";
});
export type AuthenticationProviderHandler = (
invocation: AuthenticationProviderInvocation,
) => AuthenticationResult | Promise<AuthenticationResult>;
export interface ImporterProviderInvocationByOperation {
readonly detect: TypedPluginProviderInvocation<ImporterDetectPayload> & {
readonly kind: "importer";
readonly operation: "detect";
};
readonly parse: TypedPluginProviderInvocation<ImporterParsePayload> & {
readonly kind: "importer";
readonly operation: "parse";
readonly input: Promise<PluginReadableByteStream>;
readonly output: PluginWritableByteStream;
};
}
export interface ImporterProviderResultByOperation {
readonly detect: ImporterDetectResult;
readonly parse: ImporterParseResult;
}
export type ImporterProviderOperation = keyof ImporterProviderInvocationByOperation;
export type ImporterProviderInvocation =
ImporterProviderInvocationByOperation[ImporterProviderOperation];
export type ImporterProviderResult =
ImporterProviderResultByOperation[ImporterProviderOperation];
export type ImporterProviderOperationHandler<TOperation extends ImporterProviderOperation> = (
invocation: ImporterProviderInvocationByOperation[TOperation],
) => ImporterProviderResultByOperation[TOperation] | Promise<ImporterProviderResultByOperation[TOperation]>;
export type ImporterProviderHandler = Readonly<{
[TOperation in ImporterProviderOperation]: ImporterProviderOperationHandler<TOperation>;
}>;
export interface SyncProviderInvocationByOperation {
readonly connect: TypedPluginProviderInvocation<SyncConnectPayload> & {
readonly kind: "sync";
readonly operation: "connect";
};
readonly disconnect: TypedPluginProviderInvocation<SyncDisconnectPayload | undefined> & {
readonly kind: "sync";
readonly operation: "disconnect";
};
readonly getAccount: TypedPluginProviderInvocation<SyncGetAccountPayload | undefined> & {
readonly kind: "sync";
readonly operation: "getAccount";
};
readonly getCapabilities: TypedPluginProviderInvocation<SyncGetCapabilitiesPayload | undefined> & {
readonly kind: "sync";
readonly operation: "getCapabilities";
};
readonly readObject: TypedPluginProviderInvocation<SyncReadObjectPayload> & {
readonly kind: "sync";
readonly operation: "readObject";
readonly output?: PluginWritableByteStream;
};
readonly writeObject: TypedPluginProviderInvocation<SyncWriteObjectPayload> & {
readonly kind: "sync";
readonly operation: "writeObject";
readonly input?: Promise<PluginReadableByteStream>;
};
readonly deleteObject: TypedPluginProviderInvocation<SyncDeleteObjectPayload> & {
readonly kind: "sync";
readonly operation: "deleteObject";
};
}
export interface SyncProviderResultByOperation {
readonly connect: SyncConnectResult;
readonly disconnect: SyncDisconnectResult;
readonly getAccount: SyncGetAccountResult;
readonly getCapabilities: SyncCapabilitiesResult;
readonly readObject: SyncReadObjectResult;
readonly writeObject: SyncWriteObjectResult;
readonly deleteObject: SyncDeleteObjectResult;
}
export type SyncProviderOperation = keyof SyncProviderInvocationByOperation;
export type SyncProviderInvocation =
SyncProviderInvocationByOperation[SyncProviderOperation];
export type SyncProviderResult =
SyncProviderResultByOperation[SyncProviderOperation];
export type SyncProviderOperationHandler<TOperation extends SyncProviderOperation> = (
invocation: SyncProviderInvocationByOperation[TOperation],
) => SyncProviderResultByOperation[TOperation] | Promise<SyncProviderResultByOperation[TOperation]>;
export type SyncProviderHandler = Readonly<{
[TOperation in SyncProviderOperation]: SyncProviderOperationHandler<TOperation>;
}>;
export interface PluginTerminalSessions {
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
}
export interface PluginEnvironmentChangeEvent {
readonly locale: string;
readonly theme: string;
readonly reducedMotion: boolean;
readonly highContrast: boolean;
readonly themeTokens: Readonly<Record<string, string>>;
}
export interface PluginEnvironment extends PluginEnvironmentChangeEvent {
onDidChange(listener: (event: PluginEnvironmentChangeEvent) => void): Disposable;
}
export interface PluginCredentialLeaseOptions {
readonly operationId: string;
readonly purpose: string;
readonly ttlMs?: number;
}
export interface PluginCredentialBroker {
createLease(credential: SecretRef | CredentialRef, options: PluginCredentialLeaseOptions): Promise<SecretLeaseRef>;
}
export interface PluginNetworkRequest {
readonly url: string;
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
readonly headers?: Readonly<Record<string, string>>;
readonly body?: Readonly<{ encoding: "utf8" | "base64"; data: string }>;
readonly timeoutMs?: number;
}
export interface PluginNetworkResponse {
readonly url: string;
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: Readonly<{ encoding: "base64"; data: string }>;
}
export interface PluginNetworkClient {
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
}
export interface PluginFilesystemEntry {
readonly name: string;
readonly kind: "file" | "directory" | "other";
}
export interface PluginFilesystemStat {
readonly kind: "file" | "directory" | "other";
readonly size: number;
readonly modifiedAt: number;
}
export interface PluginFilesystemClient {
readFile(path: string, options?: Readonly<{ encoding?: "utf8" | "base64"; maxBytes?: number }>): Promise<string>;
writeFile(path: string, data: string, options: Readonly<{
encoding?: "utf8" | "base64";
overwrite: true;
}>): Promise<void>;
stat(path: string): Promise<PluginFilesystemStat>;
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
}
export interface PluginCompanionRequestOptions {
readonly timeoutMs?: number;
/**
* Operation-bound one-use leases consumed by the host immediately before
* dispatching this request to the isolated companion. When present, the
* companion receives `{ payload, credentials }` instead of the raw params.
*/
readonly credentialLeases?: Readonly<Record<string, SecretLeaseRef>>;
readonly operationId?: string;
}
export interface PluginCompanionHandle extends Disposable {
readonly id: string;
request<T extends JsonValue = JsonValue>(
method: string,
params?: JsonValue,
options?: PluginCompanionRequestOptions,
): Promise<T>;
stop(): Promise<void>;
}
export interface PluginCompanionService {
start(companionId: string): Promise<PluginCompanionHandle>;
}
export interface PluginReadableByteStream extends Disposable {
readonly id: string;
/**
* Returns the next owned byte chunk or null after a normal end. Calling read
* again releases receive credit for the previous chunk, so consumers should
* finish processing one chunk before requesting the next.
*/
read(): Promise<Uint8Array | null>;
cancel(): void;
}
export interface PluginWritableByteStream extends Disposable {
readonly id: string;
write(data: Uint8Array | ArrayBuffer): Promise<void>;
end(): Promise<void>;
fail(error: Readonly<{ message: string }>): void;
cancel(): void;
}
export interface PluginStreams {
acceptReadable(streamId: string): Promise<PluginReadableByteStream>;
openWritable(streamId: string, options?: Readonly<{ windowBytes?: number }>): Promise<PluginWritableByteStream>;
}
export interface PluginContext {
readonly pluginId: PluginId;
readonly netcattyVersion: SemanticVersion;
readonly apiVersion: SemanticVersion;
readonly enabledFeatures: ReadonlySet<FeatureId>;
readonly subscriptions: DisposableStore;
readonly storage: PluginKeyValueStore;
readonly settings: PluginSettings;
readonly commands: PluginCommands;
readonly contextKeys: PluginContextKeys;
readonly views: PluginViews;
readonly providers: PluginProviders;
readonly terminals: PluginTerminalSessions;
readonly environment: PluginEnvironment;
readonly secrets: PluginSecretStore;
readonly credentials: PluginCredentialBroker;
readonly network: PluginNetworkClient;
readonly filesystem: PluginFilesystemClient;
readonly companions: PluginCompanionService;
readonly streams: PluginStreams;
readonly logger: PluginLogger;
}
export interface NetcattyPlugin {
activate(context: PluginContext): void | Disposable | Promise<void | Disposable>;
deactivate?(): void | Promise<void>;
}
export type PluginErrorCode = PluginErrorName;
export const PLUGIN_ERROR_WIRE_CODES = {
cancelled: -32001,
unknown: -32002,
invalid_argument: -32003,
deadline_exceeded: -32004,
not_found: -32005,
already_exists: -32006,
permission_denied: -32007,
resource_exhausted: -32008,
failed_precondition: -32009,
aborted: -32010,
out_of_range: -32011,
unsupported: -32012,
internal: -32013,
unavailable: -32014,
data_loss: -32015,
unauthenticated: -32016,
} as const satisfies Readonly<Record<PluginErrorCode, PluginWireErrorCode>>;
export class PluginError extends Error {
readonly code: PluginErrorCode;
readonly details?: JsonValue;
constructor(code: PluginErrorCode, message: string, details?: JsonValue) {
super(message);
this.name = "PluginError";
this.code = code;
this.details = details;
}
}
export function pluginErrorToRpcError(error: PluginError): RpcErrorObject {
const data: PluginErrorData = error.details === undefined
? { pluginCode: error.code }
: { pluginCode: error.code, details: error.details };
return {
code: PLUGIN_ERROR_WIRE_CODES[error.code],
message: error.message,
data,
};
}
export class CancellationError extends PluginError {
constructor(message = "The operation was cancelled") {
super("cancelled", message);
this.name = "CancellationError";
}
}
export class DisposableStore implements Disposable {
readonly #items = new Set<Disposable>();
#isDisposed = false;
get isDisposed(): boolean {
return this.#isDisposed;
}
add<T extends Disposable>(disposable: T): T {
if (this.#isDisposed) {
disposable.dispose();
throw new PluginError("unavailable", "Cannot add to a disposed DisposableStore");
}
this.#items.add(disposable);
return disposable;
}
delete(disposable: Disposable): boolean {
return this.#items.delete(disposable);
}
clear(): void {
const items = [...this.#items];
this.#items.clear();
const errors: unknown[] = [];
for (const item of items) {
try {
item.dispose();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, "One or more plugin disposables failed");
}
}
dispose(): void {
if (this.#isDisposed) return;
this.#isDisposed = true;
this.clear();
}
}
class MutableCancellationToken implements CancellationToken {
readonly #listeners = new Set<CancellationListener>();
#isCancellationRequested = false;
get isCancellationRequested(): boolean {
return this.#isCancellationRequested;
}
onCancellationRequested(listener: CancellationListener): Disposable {
if (this.#isCancellationRequested) {
queueMicrotask(listener);
return { dispose() {} };
}
this.#listeners.add(listener);
return {
dispose: () => {
this.#listeners.delete(listener);
},
};
}
cancel(): void {
if (this.#isCancellationRequested) return;
this.#isCancellationRequested = true;
const listeners = [...this.#listeners];
this.#listeners.clear();
const errors: unknown[] = [];
for (const listener of listeners) {
try {
listener();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, "One or more cancellation listeners failed");
}
}
dispose(): void {
this.#listeners.clear();
}
}
export class CancellationTokenSource implements Disposable {
readonly #token = new MutableCancellationToken();
#isDisposed = false;
get token(): CancellationToken {
return this.#token;
}
cancel(): void {
if (!this.#isDisposed) this.#token.cancel();
}
dispose(cancel = false): void {
if (this.#isDisposed) return;
try {
if (cancel) this.#token.cancel();
} finally {
this.#token.dispose();
this.#isDisposed = true;
}
}
}
export function definePlugin<T extends NetcattyPlugin>(plugin: T): T {
return plugin;
}
export function throwIfCancellationRequested(token: CancellationToken): void {
if (token.isCancellationRequested) throw new CancellationError();
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}