跳转到内容

Native 插件开发

本文档介绍 OxideTerm Native 插件模型。它和 Tauri/Web 插件模型不同:Native 插件不运行 React 组件,不注入 CSS,也不会通过 WebView 执行 main.js。Native 插件通过 plugin.json 被发现,然后要么只提供清单声明的元数据,要么通过宿主拥有的进程/WASM 运行时桥接运行。

  1. 写出一个可运行插件
  2. 运行时通信过程
  3. 在插件代码里调用宿主
  4. 界面与事件写法
  5. 常见失败检查
  6. Native 插件模型
  7. Native 插件与 Tauri 插件的区别
  8. 插件目录
  9. 最小清单插件
  10. 进程运行时插件
  11. 协议帧
  12. 运行时注册
  13. 声明式 Native 界面
  14. 宿主 API 调用
  15. 权限与能力
  16. 设置、存储与凭据
  17. 终端、SFTP、转发、IDE 与 AI
  18. 打包与安装
  19. 接口参考
  20. 宿主 API 参考
  21. 事件参考
  22. 调试
  23. 迁移说明

先从进程插件开始。进程运行时最容易调试,因为所有消息都是 stdin/stdout 上的一行一个 JSON 对象。

在用户插件目录里创建这个目录:

hello-native/
plugin.json
bin/
hello.js

进程入口必须是插件目录内的真实可执行文件。macOS/Linux 上需要给脚本加可执行权限:

Terminal window
chmod +x hello-native/bin/hello.js

使用这个清单:

{
"id": "com.example.hello-native",
"name": "Hello Native",
"version": "0.1.0",
"description": "Minimal native process plugin.",
"runtime": {
"kind": "process",
"entry": "bin/hello.js"
},
"contributes": {
"sidebarPanels": [
{
"id": "hello-panel",
"title": "Hello",
"icon": "panel-left",
"position": "top"
}
],
"settings": [
{
"id": "message",
"type": "string",
"default": "Hello from a native plugin",
"title": "Message"
}
],
"apiCommands": [
"get_app_version"
]
}
}

使用这个进程入口:

#!/usr/bin/env node
const readline = require("node:readline");
const pluginId = "com.example.hello-native";
let nextHostRequest = 1;
const pendingHostCalls = new Map();
// Keep stdout reserved for protocol frames.
function writeFrame(payload, requestId = null) {
process.stdout.write(JSON.stringify({
protocolVersion: 1,
requestId,
payload
}) + "\n");
}
function respondOk(requestId, value) {
writeFrame({
requestId,
result: {
status: "ok",
value
}
}, requestId);
}
function respondError(requestId, code, message) {
writeFrame({
requestId,
result: {
status: "error",
error: {
code,
message,
recoverable: false
}
}
}, requestId);
}
function registerPanel() {
writeFrame({
type: "registerContribution",
registration: {
registrationId: "hello-panel-view",
pluginId,
kind: "sidebar-panel",
metadata: {
panelId: "hello-panel",
schema: {
kind: "form",
title: "Hello Native",
sections: [
{
id: "main",
controls: [
{
kind: "markdown",
text: "This panel was registered by a native process plugin."
},
{
kind: "button",
id: "refresh",
label: "Refresh"
}
]
}
]
}
}
}
});
}
// Returnable host calls are matched by requestId.
function callHost(namespace, method, args = {}) {
const requestId = `host-${nextHostRequest++}`;
writeFrame({
type: "callHostApi",
requestId,
namespace,
method,
args
});
return new Promise((resolve, reject) => {
pendingHostCalls.set(requestId, { resolve, reject });
});
}
// Host-call responses arrive on stdin like normal host requests.
function handleHostResponse(payload) {
const pending = pendingHostCalls.get(payload.requestId);
if (!pending) {
return false;
}
pendingHostCalls.delete(payload.requestId);
if (payload.result.status === "ok") {
pending.resolve(payload.result.value);
} else {
pending.reject(new Error(payload.result.error.message));
}
return true;
}
// Host requests and host-call responses share the same input stream.
async function handleRequest(envelope) {
const request = envelope.payload;
if (handleHostResponse(request)) {
return;
}
const requestId = request.requestId;
switch (request.kind.type) {
case "activate":
registerPanel();
writeFrame({
type: "runtimeReady"
});
respondOk(requestId, { activated: true });
break;
case "sendEvent":
respondOk(requestId, { received: request.kind.event.name });
break;
case "health":
respondOk(requestId, { ok: true });
break;
case "deactivate":
case "kill":
respondOk(requestId, { stopped: true });
process.exit(0);
break;
case "dispatchCommand":
try {
const version = await callHost("api", "invoke", {
command: "get_app_version",
args: {}
});
respondOk(requestId, { version });
} catch (error) {
respondError(requestId, "command_failed", error.message);
}
break;
default:
respondError(requestId, "unsupported_request", `Unsupported request ${request.kind.type}`);
}
}
readline.createInterface({
input: process.stdin,
crlfDelay: Infinity
}).on("line", (line) => {
if (!line.trim()) {
return;
}
handleRequest(JSON.parse(line)).catch((error) => {
process.stderr.write(`Plugin error: ${error.stack || error.message}\n`);
});
});

这个例子展示了最重要的约定:

  • stdout 只能写协议帧,一行一个 JSON 对象。
  • stderr 才能写给人看的诊断文本。
  • 插件必须用相同的 requestId 回答激活请求。
  • 运行时界面不是 React,而是通过 sidebar-paneltab 注册的声明式结构。
  • 需要返回值的宿主调用用出站 callHostApi 帧发送;宿主会把结果写回插件的 stdin。

进程运行时只有一条按行分隔的通信通道。

sequenceDiagram
    participant Host as OxideTerm 宿主
    participant Plugin as 插件进程
    Host->>Plugin: stdin 写入 activate 请求
    Plugin-->>Host: stdout 写出 registerContribution sidebar-panel
    Plugin-->>Host: stdout 写出 runtimeReady
    Plugin-->>Host: stdout 写出 activate ok 响应
    Host->>Plugin: stdin 写入 sendEvent / dispatchCommand / health
    Plugin-->>Host: stdout 写出相同 requestId 的响应

宿主发给插件的激活请求。这里缩短了 manifest 对象;真实请求会携带解析后的 plugin.json

{
"protocolVersion": 1,
"requestId": "activate:com.example.hello-native",
"payload": {
"requestId": "activate:com.example.hello-native",
"kind": {
"type": "activate",
"manifest": {
"id": "com.example.hello-native",
"name": "Hello Native",
"version": "0.1.0"
},
"permissions": {
"capabilities": [],
"allowedHostApis": [
"api.invoke",
"app.getVersion",
"settings.get"
]
}
},
"timeoutMs": 5000
}
}

插件回给宿主的激活响应:

{
"protocolVersion": 1,
"requestId": "activate:com.example.hello-native",
"payload": {
"requestId": "activate:com.example.hello-native",
"result": {
"status": "ok",
"value": {
"activated": true
}
}
}
}

错误响应:

{
"protocolVersion": 1,
"requestId": "activate:com.example.hello-native",
"payload": {
"requestId": "activate:com.example.hello-native",
"result": {
"status": "error",
"error": {
"code": "invalid_config",
"message": "Missing required plugin setting",
"recoverable": false
}
}
}
}

插件发给宿主的 Host API 调用:

{
"protocolVersion": 1,
"requestId": null,
"payload": {
"type": "callHostApi",
"requestId": "host-1",
"namespace": "app",
"method": "getVersion",
"args": {}
}
}

宿主写回插件的 Host API 响应:

{
"protocolVersion": 1,
"requestId": "host-1",
"payload": {
"requestId": "host-1",
"result": {
"status": "ok",
"value": "0.1.0"
}
}
}

进程桥会在消息进入工作区之前拒绝无效帧。常见拒绝原因包括 protocolVersion 不支持、响应缺少 requestId、响应 id 不匹配、注册信息不属于当前插件,以及 stdout 输出了非 JSON 文本。

宿主调用不是全局 JavaScript 函数,而是协议消息。一次调用必须同时满足两个条件:

  • Host API 名称在插件权限集中被允许,可以是 terminal.getActiveTarget 这样的精确名称,也可以是 terminal.* 这样的命名空间通配。
  • api.invoke 来说,args.command 还必须出现在 contributes.apiCommands 里。

读取插件设置:

{
"type": "callHostApi",
"requestId": "host-2",
"namespace": "settings",
"method": "get",
"args": {
"key": "message"
}
}

读取活跃终端目标:

{
"type": "callHostApi",
"requestId": "host-3",
"namespace": "terminal",
"method": "getActiveTarget",
"args": {}
}

向活跃终端写入文本:

{
"type": "callHostApi",
"requestId": "host-4",
"namespace": "terminal",
"method": "writeToActive",
"args": {
"text": "pwd\n"
}
}

通过 SFTP 读取远程文件:

{
"type": "callHostApi",
"requestId": "host-5",
"namespace": "sftp",
"method": "readFile",
"args": {
"nodeId": "node-1",
"path": "/etc/hostname"
}
}

这个 SFTP 调用还需要 filesystem.read 能力。写入类操作需要 filesystem.write。端口转发变更需要 network.forward

导入 .oxide 包:

{
"type": "callHostApi",
"requestId": "host-6",
"namespace": "sync",
"method": "importOxide",
"args": {
"fileData": [1, 2, 3],
"password": "user-entered-password",
"conflictStrategy": "rename",
"importAppSettings": true,
"importPluginSettings": true
}
}

不要记录或回显密码、凭据值、终端缓冲区、连接配置、原始导入/导出载荷。

注册标签页视图:

{
"type": "registerContribution",
"registration": {
"registrationId": "hello-tab-view",
"pluginId": "com.example.hello-native",
"kind": "tab",
"metadata": {
"tabId": "hello-tab",
"schema": {
"kind": "form",
"title": "Hello",
"controls": [
{
"kind": "markdown",
"text": "This tab is rendered by OxideTerm, not by plugin HTML."
}
]
}
}
}
}

通过 event-subscription 注册订阅:

{
"type": "registerContribution",
"registration": {
"registrationId": "watch-active-node",
"pluginId": "com.example.hello-native",
"kind": "event-subscription",
"metadata": {
"namespace": "sessions",
"method": "onNodeStateChange",
"nodeId": "node-1"
}
}
}

事件触发时,宿主会给进程发送普通的 sendEvent 请求:

{
"protocolVersion": 1,
"requestId": "event:sessions.nodeStateChanged",
"payload": {
"requestId": "event:sessions.nodeStateChanged",
"kind": {
"type": "sendEvent",
"event": {
"name": "sessions.nodeStateChanged",
"payload": {
"nodeId": "node-1"
}
}
}
}
}

插件仍然必须响应:

{
"protocolVersion": 1,
"requestId": "event:sessions.nodeStateChanged",
"payload": {
"requestId": "event:sessions.nodeStateChanged",
"result": {
"status": "ok",
"value": {
"handled": true
}
}
}
}

自定义插件事件使用 events.emitevents.on。事件会按插件 id 作用域隔离,因此一个插件不能抢占任意全局事件名。

如果插件没有激活:

  • 检查 runtime.entry 是否指向插件目录内存在的可执行文件。
  • 检查进程是否只向 stdout 输出 JSON Lines。
  • 检查激活响应是否同时包含 envelope requestId 和 payload requestId
  • 检查 protocolVersion 是否为 1
  • 检查 stderr 里的插件诊断信息。

如果界面没有出现:

  • contributes.tabscontributes.sidebarPanels 必须声明界面 id。
  • 运行时注册必须使用相同的 tabIdpanelId
  • registration.pluginId 必须匹配清单 id
  • metadata.schema.kind 应该是 form
  • buttontextpasswordnumbercheckboxselect 等可交互控件需要稳定的 id

如果 Host API 调用失败:

  • 确认调用出现在 allowedHostApis 中。
  • 确认方法名和参数名与下方参考表一致。
  • api.invoke,确认命令出现在 contributes.apiCommands 中。
  • 对 SFTP 或端口转发,确认具备对应能力。
  • 对凭据和密码,确认插件没有通过日志或界面文本发送敏感值。
flowchart TB
    Manifest["plugin.json"] --> Discovery["Native 插件发现"]
    Discovery --> Registry["插件注册表"]
    Registry --> Contributions["清单贡献"]
    Registry --> RuntimePlan["运行计划"]

    RuntimePlan --> ManifestOnly["manifest-only"]
    RuntimePlan --> Process["process 运行时"]
    RuntimePlan --> Wasm["wasm 运行时"]
    RuntimePlan --> Legacy["legacy-js 可见但不执行"]

    Process --> Protocol["JSON Lines 协议"]
    Wasm --> Protocol
    Protocol --> HostApi["宿主 API 解析器"]
    Protocol --> RuntimeRegs["运行时注册"]
    RuntimeRegs --> UI["Native GPUI 页面"]
    HostApi --> Domains["SSH · SFTP · 终端 · IDE · AI · 同步 · 设置"]

宿主持有所有持久化和安全敏感边界:

  • 清单解析与校验。
  • 运行时启动和超时处理。
  • 贡献注册和清理。
  • 宿主 API 权限检查。
  • 插件设置、存储和凭据。
  • 通过 Native GPUI 控件渲染界面。

插件不会拿到原始 GPUI 元素、DOM 节点、React 实例、SSH 传输句柄,或越过宿主 API 的直接文件系统访问。

区域Tauri/Web 插件Native 插件
运行时通过动态导入加载 ESM main.jsruntime.kindprocesswasmmanifest-only
界面React 组件和 CSSGPUI 渲染的声明式 Native UI 结构
共享模块window.__OXIDE__不可用
样式CSS 和主题变量只使用宿主拥有的 Native 控件
宿主 API冻结的 PluginContext 对象带命名空间、方法和参数的 JSON 协议调用
旧 JS 插件在 Tauri 中可执行显示为 legacy-js,不执行
安全边界浏览器膜层加 Tauri 命令运行时桥、权限门、作用域存储和凭据

如果插件只有 main 而没有 Native runtime 块,Native 会把它归类为旧 JS 插件。它可以出现在插件管理器中用于迁移,但不会被执行。

插件位于应用配置目录下:

<config-dir>/plugins/<plugin-id>/
plugin.json
bin/
plugin-runtime
assets/
locales/

使用 oxideterm paths --json 查看当前生效配置目录。CLI 可以在无界面流程中启用、禁用和检查插件状态,但交互式安装、更新和卸载由桌面插件管理器负责。

清单插件适合静态元数据、声明式设置、AI 工具元数据和未来包迁移。它不执行代码。

{
"id": "com.example.audit",
"name": "Audit Helper",
"version": "0.1.0",
"description": "Adds audit-related settings and tool metadata.",
"author": "Example",
"runtime": {
"kind": "manifest-only",
"entry": ""
},
"contributes": {
"settings": [
{
"id": "scanDepth",
"type": "number",
"default": 3,
"title": "Scan depth",
"description": "Maximum audit depth."
}
],
"aiTools": [
{
"name": "audit_summarize",
"description": "Summarize visible connection and terminal state.",
"capabilities": ["state.list", "terminal.observe"],
"risk": "read",
"targetKinds": ["ssh-node", "terminal-session"]
}
]
}
}

支持的设置类型是 stringnumberbooleanselectselect 设置必须提供 options,选项值必须是字符串或数字。

进程插件是插件目录内的可执行文件。宿主通过 stdin/stdout 启动它,并交换换行分隔的 JSON 协议帧。

{
"id": "com.example.native-dashboard",
"name": "Native Dashboard",
"version": "0.1.0",
"runtime": {
"kind": "process",
"entry": "./bin/native-dashboard"
},
"contributes": {
"tabs": [
{ "id": "dashboard", "title": "Dashboard", "icon": "LayoutDashboard" }
],
"sidebarPanels": [
{ "id": "dashboard-panel", "title": "Dashboard", "icon": "Activity", "position": "bottom" }
],
"terminalHooks": {
"shortcuts": [
{ "key": "Ctrl+Shift+D", "command": "dashboard.refresh" }
]
},
"apiCommands": [
"app.getVersion",
"connections.getAll",
"ui.showToast"
]
}
}

规则:

  • runtime.entry 必须是插件目录内的相对路径。
  • processwasm 运行时的入口文件必须存在。
  • 进程不能把普通人类日志写到 stdout。stdout 是协议通道。
  • 诊断文本写到 stderr。
  • 每个协议帧是一行 JSON 对象,以 \n 结尾。

宿主会把每个请求和响应包在 envelope 中:

{
"protocolVersion": 1,
"requestId": "activate-1",
"payload": {
"requestId": "activate-1",
"kind": {
"type": "activate",
"manifest": {
"id": "com.example.runtime",
"name": "Example Runtime",
"version": "0.1.0"
},
"permissions": {
"capabilities": [],
"allowedHostApis": []
}
},
"timeoutMs": 3000
}
}

插件需要用相同 requestId 响应:

{
"protocolVersion": 1,
"requestId": "activate-1",
"payload": {
"requestId": "activate-1",
"result": {
"status": "ok",
"value": { "activated": true }
}
}
}

宿主可能发送 activatedeactivatedispatchCommandsendEventcallHostApihealthkill 等请求。插件可以发出 runtimeReadyregisterContributiondisposeContributioncallHostApiemitEventreportProgresslogruntimeError 等出站帧。

运行时注册让正在运行的进程/WASM 插件增加宿主持有的贡献:

{
"protocolVersion": 1,
"requestId": null,
"payload": {
"type": "registerContribution",
"registration": {
"registrationId": "cmd-refresh",
"pluginId": "com.example.native-dashboard",
"kind": "command",
"metadata": {
"id": "dashboard.refresh",
"label": "Refresh Dashboard",
"icon": "RefreshCw",
"section": "Dashboard"
}
}
}
}

常见注册类型:

类型用途
command添加命令面板动作,并派发回插件
keybinding在内置快捷键未命中后添加快捷键
context-menu为声明目标添加上下文菜单项
status-bar添加宿主持有的状态栏项
tab为已声明标签页注册声明式界面
sidebar-panel为已声明面板注册声明式界面
event-subscription订阅宿主事件
terminal-input-interceptor转换或抑制终端输入
terminal-output-processor在解析器前处理终端输出
terminal-shortcut把终端快捷键处理附着到命令
progress创建宿主持有的进度报告器

注册必须使用发出它的同一个插件 id。释放注册是幂等的,并由宿主执行。

Native 插件通过注册界面结构渲染界面,不注册组件。

{
"protocolVersion": 1,
"requestId": null,
"payload": {
"type": "registerContribution",
"registration": {
"registrationId": "tab-dashboard",
"pluginId": "com.example.native-dashboard",
"kind": "tab",
"metadata": {
"tabId": "dashboard",
"schema": {
"kind": "form",
"title": "Dashboard",
"sections": [
{
"id": "overview",
"title": "Overview",
"controls": [
{ "kind": "markdown", "text": "Native plugin UI is host-rendered." },
{ "kind": "keyValue", "label": "Status", "value": "Ready" },
{ "kind": "progress", "label": "Sync", "value": 42 },
{ "kind": "button", "id": "refresh", "label": "Refresh" }
]
}
]
}
}
}
}
}

支持的控件类型:

类型说明
text, password, number, checkbox, select字段控件,需要 id
button只有存在 id 且未禁用/加载中时才可操作
markdown宿主渲染的文本块
code, codeBlock, code-block代码块
statusBadge, status-badge状态指示
progress进度显示
table, list结构化数据显示
emptyState, empty-state空状态
divider分隔线
keyValue, key-value, keyValueRow, key-value-row键值行

按钮点击会作为插件 UI 事件传回。焦点、布局、无障碍、主题和控件渲染都由宿主持有。

运行时插件通过命名空间、方法和 JSON 参数调用宿主 API:

{
"protocolVersion": 1,
"requestId": null,
"payload": {
"type": "callHostApi",
"requestId": "host-1",
"namespace": "connections",
"method": "getAll",
"args": {}
}
}

宿主会拒绝权限集中不允许的调用。contributes.apiCommands 也用于声明许多宿主调用。优先声明精确项,例如 connections.getAll;只有插件确实需要整个命名空间时才使用通配。

常见命名空间:

命名空间常见用途
connections读取保存/在线连接快照
sessions读取节点树和活跃节点状态
terminal观察缓冲区或发送已批准输入
sftp通过节点拥有的 SFTP 列出、读取、写入远端文件
forward列出、创建、停止转发
transfers观察传输状态
profiler读取节点指标
eventLog读取应用事件日志
ide观察 IDE 项目和打开文件状态
ai读取已脱敏 AI 元数据
app主题、平台、版本和设置快照
settings插件设置和可同步设置
storage插件作用域 JSON KV
sync.oxide、保存连接、插件设置和同步元数据
secrets插件作用域凭据存储
uitoast、确认对话框、布局和进度

Native 使用显式能力门。当前共享能力名称包括:

能力含义
filesystem.read通过已批准宿主 API 读取文件元数据或内容
filesystem.write通过已批准宿主 API 修改文件
network.forward创建或管理转发/网络桥接行为

运行时会在 activate 请求里收到最终生效的权限集。插件应把该请求当作准确信息来源:清单可以声明插件意图,但 Host API 调用仍然必须出现在 permissions.allowedHostApis 中,带能力门的调用还必须具备对应能力。

AI 工具元数据还可以声明 terminal.observeterminal.sendstate.listsettings.readsettings.writeplugin.invoke 等语义能力。这些声明用于描述工具行为,服务于宿主和模型侧展示;它们不会绕过运行时权限检查。

按数据性质选择最小边界:

数据边界说明
用户可见插件选项contributes.settingssettings.*类型化值;不是凭据时可安全导入导出
插件内部缓存storage.*插件作用域 JSON KV,有大小限制
token、密码、密钥secrets.*系统钥匙串支持的插件作用域凭据存储
跨机器插件偏好插件设置导入导出或 .oxide不要包含原始凭据,除非加密便携流程明确支持

不要把凭据写进插件 id、名称、标签、日志、AI 提示词、事件载荷或支持包。

Native 插件应尽量使用稳定节点 id:

  • 使用节点/会话快照,不要假设当前终端标签页就是所有者。
  • 终端输入钩子出错或超时时必须失败开放。
  • SFTP 修改操作需要文件写能力和在线节点。
  • 转发调用需要网络转发能力,并应处理已挂起节点。
  • IDE API 暴露项目和打开文件元数据,不暴露任意编辑器内部状态。
  • AI API 暴露已脱敏元数据,避免暴露工具消息内容。

破坏性动作应表示为宿主可见命令或带清晰风险元数据的 AI 工具。

推荐包形状:

com.example.native-dashboard/
plugin.json
bin/
native-dashboard
README.md
LICENSE

打包规则:

  • 插件根目录或单层嵌套插件目录必须包含 plugin.json
  • 归档条目不能逃逸插件目录。
  • 包大小和条目数量应低于宿主限制。
  • 一个包优先只包含一个插件 id。
  • 使用类似 semver 的版本,便于更新检查比较。
  • 除非入口本身可移植,否则运行时二进制应按平台分发。

开发时:

  1. 将插件目录复制到 <config-dir>/plugins/
  2. 打开桌面插件管理器。
  3. 启用或重新加载插件。
  4. 检查状态、错误和权限。
  5. 只在无界面状态/设置检查中使用 CLI:
Terminal window
oxideterm plugins list --json
oxideterm plugins enable com.example.native-dashboard --dry-run --json
oxideterm plugins settings export --json
interface NativePluginManifest {
id: string;
name: string;
version: string;
description?: string;
author?: string;
main?: string; // Tauri 旧 JS 入口;Native 可见但不执行。
engines?: { oxideterm?: string };
manifestVersion?: number;
format?: string;
assets?: string;
styles?: string[];
sharedDependencies?: Record<string, string>;
repository?: string;
checksum?: string;
contributes?: NativePluginContributes;
locales?: string;
runtime?: NativePluginRuntime;
}
interface NativePluginRuntime {
kind: 'wasm' | 'process' | 'manifest-only';
entry: string;
}
interface NativePluginContributes {
tabs?: NativePluginTabDef[];
sidebarPanels?: NativePluginSidebarDef[];
settings?: NativePluginSettingDef[];
terminalHooks?: NativePluginTerminalHooksDef;
terminalTransports?: string[];
connectionHooks?: Array<'onConnect' | 'onDisconnect' | 'onReconnect' | 'onLinkDown'>;
aiTools?: NativePluginAiToolDef[];
apiCommands?: string[];
}
interface NativePluginTabDef {
id: string;
title: string;
icon: string;
}
interface NativePluginSidebarDef {
id: string;
title: string;
icon: string;
position?: 'top' | 'bottom';
}
interface NativePluginSettingDef {
id: string;
type: 'string' | 'number' | 'boolean' | 'select';
default: unknown;
title: string;
description?: string;
options?: Array<{ label: string; value: string | number }>;
}
interface NativePluginTerminalHooksDef {
inputInterceptor?: boolean;
outputProcessor?: boolean;
shortcuts?: Array<{ key: string; command: string }>;
}
interface NativePluginAiToolDef {
name: string;
description: string;
parameters?: unknown;
capabilities?: string[];
risk?: 'read' | 'write-file' | 'execute-command' | 'interactive-input' | 'destructive' | 'network-expose' | 'settings-change' | 'credential-sensitive';
targetKinds?: Array<'local-shell' | 'ssh-node' | 'terminal-session' | 'sftp-session' | 'ide-workspace' | 'app-tab' | 'mcp-server' | 'rag-index'>;
resultSchema?: unknown;
}

校验规则:

  • idnameversion、贡献 id、标题和图标必须是非空文本。
  • 相对路径必须留在插件目录内。
  • terminalTransports 当前接受 telnet
  • apiCommands 条目是宿主 API 名称,例如 connections.getAllsftp.listDir
  • 只有旧 main 且没有 runtime 时,插件状态为 legacy-js,不会执行。
interface PluginProtocolEnvelope<T> {
protocolVersion: 1;
requestId?: string | null;
payload: T;
}
interface PluginRequest {
requestId: string;
kind:
| { type: 'activate'; manifest: NativePluginManifest; permissions: PluginPermissionSet }
| { type: 'deactivate' }
| { type: 'callHostApi'; namespace: string; method: string; args?: unknown }
| { type: 'dispatchCommand'; command: string; args?: unknown }
| { type: 'sendEvent'; event: PluginEvent }
| { type: 'cancelRequest'; requestId: string }
| { type: 'health' }
| { type: 'kill' };
timeoutMs?: number;
}
interface PluginResponse {
requestId: string;
result:
| { status: 'ok'; value: unknown }
| { status: 'error'; error: PluginError };
}
interface PluginError {
kind: string;
code: string;
message: string;
}
interface PluginPermissionSet {
capabilities: string[];
allowedHostApis: string[];
}
interface PluginEvent {
name: string;
payload?: unknown;
}
type PluginOutboundMessage =
| { type: 'registerContribution'; registration: PluginRegistration }
| { type: 'disposeContribution'; registrationId: string }
| { type: 'log'; level: 'debug' | 'info' | 'warn' | 'error'; message: string }
| { type: 'reportProgress'; registrationId: string; value: unknown }
| { type: 'runtimeReady' }
| { type: 'runtimeError'; error: PluginError }
| { type: 'emitEvent'; event: PluginEvent }
| { type: 'callHostApi'; requestId: string; namespace: string; method: string; args?: unknown };
interface PluginRegistration {
registrationId: string;
pluginId: string;
kind:
| 'command'
| 'keybinding'
| 'context-menu'
| 'status-bar'
| 'tab'
| 'sidebar-panel'
| 'terminal-input-interceptor'
| 'terminal-output-processor'
| 'terminal-shortcut'
| 'event-subscription'
| 'progress';
metadata?: unknown;
}
注册类型必需元数据可选元数据
commandidcommandlabeliconshortcutsection
keybindingkeybindingkeycommandlabelwhen
context-menutargetitems目标相关菜单项元数据
status-bartextalignmenticontooltip
tabtabIdschemaschema 内的 title
sidebar-panelpanelIdschemaposition、schema 内的 title
event-subscriptioneventnamespace + method部分事件族支持 nodeId 过滤
terminal-input-interceptorcommandid
terminal-output-processorcommandid
terminal-shortcutcommandid使用清单里声明的快捷键
progressid 或生成的 idtitlemessage
interface NativePluginDeclarativeUiSchema {
kind?: 'form';
title?: string;
description?: string;
sections?: NativePluginDeclarativeUiSection[];
controls?: NativePluginDeclarativeUiControl[];
}
interface NativePluginDeclarativeUiSection {
id: string;
title?: string;
description?: string;
controls?: NativePluginDeclarativeUiControl[];
}
interface NativePluginDeclarativeUiControl {
kind:
| 'text'
| 'password'
| 'number'
| 'checkbox'
| 'select'
| 'button'
| 'markdown'
| 'code'
| 'codeBlock'
| 'code-block'
| 'statusBadge'
| 'status-badge'
| 'progress'
| 'table'
| 'list'
| 'emptyState'
| 'empty-state'
| 'divider'
| 'keyValue'
| 'key-value'
| 'keyValueRow'
| 'key-value-row';
id?: string;
label?: string;
description?: string;
value?: unknown;
text?: string;
language?: string;
options?: Array<{ label: string; value: unknown }>;
rows?: unknown[];
columns?: string[];
disabled?: boolean;
loading?: boolean;
}

textpasswordnumbercheckboxselectbutton 控件需要 id。按钮只有在存在 id、未禁用且不处于加载中时才可操作。

所有宿主 API 使用同一种调用结构:

interface HostCall {
requestId: string;
namespace: string;
method: string;
args?: unknown;
}

调用必须出现在 allowedHostApis 中。支持精确名称和命名空间通配,例如 connections.getAllconnections.*

api.invoke 还有额外白名单:args.command 必须同时出现在 contributes.apiCommands 中。

api.invoke 命令Native 适配器
ssh_get_pool_statsSSH 连接池统计
list_connections保存/已知连接快照
get_app_version应用版本
get_system_info平台、架构、系统和系统族
sftp_cancel_transfertransferId 取消传输
sftp_pause_transfertransferId 暂停传输
sftp_resume_transfertransferId 恢复传输
sftp_transfer_stats传输队列统计
node_sftp_initsftp.init 适配器
node_sftp_list_dirsftp.listDir 适配器
node_sftp_statsftp.stat 适配器
node_sftp_previewsftp.preview 适配器
node_sftp_writesftp.write 适配器
node_sftp_downloadsftp.download 适配器
node_sftp_uploadsftp.upload 适配器
node_sftp_mkdirsftp.mkdir 适配器
node_sftp_deletesftp.delete 适配器
node_sftp_delete_recursivesftp.deleteRecursive 适配器
node_sftp_renamesftp.rename 适配器
node_sftp_download_dirsftp.downloadDir 适配器
node_sftp_upload_dirsftp.uploadDir 适配器
node_sftp_tar_probesftp.tarProbe 适配器
node_sftp_tar_uploadsftp.tarUpload 适配器
node_sftp_tar_downloadsftp.tarDownload 适配器
list_port_forwardsforward.list 适配器
create_port_forwardforward.create 适配器
stop_port_forwardforward.stop 适配器
delete_port_forwardforward.delete 适配器
restart_port_forwardforward.restart 适配器
update_port_forwardforward.update 适配器
get_port_forward_statsforward.getStats 适配器
stop_all_forwardsforward.stopAll 适配器
plugin_http_requestHTTP/HTTPS 请求适配器,正文上限 10 MiB
宿主 API参数返回
api.invoke{ command: string, args?: object }contributes.apiCommands 已声明命令的适配器结果
app.getTheme{}{ name, isDark }
app.getSettings{ category: string }设置部分 JSON
app.getVersion{}版本字符串
app.getPlatform{}平台字符串
app.getLocale{}语言字符串
app.getPoolStats{}类似 { activeConnections, totalSessions } 的统计
app.refreshAfterExternalSync{}单向刷新工作区效果
ui.getLayout{}布局快照
ui.registerTabView{ tabId: string, schema: NativePluginDeclarativeUiSchema }声明式标签页注册结果
ui.registerSidebarPanel{ panelId: string, schema: NativePluginDeclarativeUiSchema }声明式侧边栏注册结果
ui.openTab{ tabId: string }打开或聚焦已声明插件标签页
ui.showToast{ title?: string, description?: string, variant?: string }单向 toast 效果
ui.showNotification{ title?: string, body?: string, severity?: string }单向通知效果
ui.showConfirm{ title: string, description: string }boolean
ui.showProgress{ title?: string, message?: string, registrationId?: string, id?: string }{ id, registrationId }
events.emit{ name: string, payload?: unknown }{ emitted: true, event }
settings.get{ key: string }插件设置值或 null
settings.set{ key: string, value: unknown }单向设置写入
settings.exportSyncableSettings{}{ revision, exportedAt, payload, warnings }
settings.applySyncableSettings{ payload: object }{ revision, appliedPayload, warnings }
i18n.getLanguage{}语言字符串
i18n.t{ key: string }翻译文本或回退 key
宿主 API参数返回
connections.getAll{}连接快照
connections.get{ connectionId: string }连接快照或 null
connections.getState{ connectionId: string }连接状态或 null
connections.getByNode{ nodeId: string }连接快照或 null
sessions.getTree{}节点树快照
sessions.getActiveNodes{}活跃/已连接节点快照
sessions.getNodeState{ nodeId: string }节点状态或 null
eventLog.getEntries{ severity?: string, category?: string, limit?: number }事件日志条目
宿主 API参数返回
terminal.getActiveTarget{}活跃终端目标快照
terminal.getNodeBuffer{ nodeId: string }终端缓冲文本或 null
terminal.getNodeSelection{ nodeId: string }选中文本或 null
terminal.search{ nodeId: string, query: string, regex?: boolean, caseSensitive?: boolean, wholeWord?: boolean, maxResults?: number }搜索匹配
terminal.getScrollBuffer{ nodeId: string, start?: number, limit?: number }有界滚动缓冲区行
terminal.getBufferSize{ nodeId: string }缓冲区大小摘要
terminal.writeToActive{ text: string }boolean
terminal.writeToNode{ nodeId: string, text: string }boolean
terminal.clearBuffer{ nodeId: string }清理结果
terminal.openTelnet{ host: string, port?: number }{ sessionId, info }
宿主 API能力参数返回
sftp.initfilesystem.read{ nodeId: string }会话状态
sftp.listDirfilesystem.read{ nodeId: string, path: string }目录条目
sftp.statfilesystem.read{ nodeId: string, path: string }文件元数据
sftp.readFilefilesystem.read{ nodeId: string, path: string }文件内容载荷
sftp.previewfilesystem.read{ nodeId: string, path: string }预览载荷
sftp.downloadfilesystem.read{ nodeId: string, remotePath: string, localPath: string }传输结果
sftp.downloadDirfilesystem.read{ nodeId: string, remotePath: string, localPath: string }传输结果
sftp.tarProbefilesystem.read{ nodeId: string, path: string }tar 能力/探测结果
sftp.tarDownloadfilesystem.read{ nodeId: string, remotePath: string, localPath: string }传输结果
sftp.writeFilefilesystem.write{ nodeId: string, path: string, content: string }写入结果
sftp.writefilesystem.write{ nodeId: string, path: string, content: string }写入结果
sftp.uploadfilesystem.write{ nodeId: string, localPath: string, remotePath: string }传输结果
sftp.uploadDirfilesystem.write{ nodeId: string, localPath: string, remotePath: string }传输结果
sftp.tarUploadfilesystem.write{ nodeId: string, localPath: string, remotePath: string }传输结果
sftp.mkdirfilesystem.write{ nodeId: string, path: string }目录结果
sftp.deletefilesystem.write{ nodeId: string, path: string }删除结果
sftp.deleteRecursivefilesystem.write{ nodeId: string, path: string }递归删除结果
sftp.renamefilesystem.write{ nodeId: string, oldPath: string, newPath: string }重命名结果
transfers.getAll只读{}传输快照
transfers.getByNode只读{ nodeId: string }节点传输快照
宿主 API能力参数返回
forward.list只读{ nodeId?: string }活跃转发规则
forward.listSavedForwards只读{}保存转发规则
forward.exportSavedForwardsSnapshot只读{}保存转发同步快照
forward.applySavedForwardsSnapshotnetwork.forward{ snapshot: object, strategy?: string }应用结果
forward.createnetwork.forward`{ nodeId: string, type: ‘local''remote'
forward.stopnetwork.forward{ id: string }停止结果
forward.deletenetwork.forward{ id: string }删除结果
forward.restartnetwork.forward{ id: string }重启结果
forward.updatenetwork.forward{ id: string, ...changes }更新结果
forward.stopAllnetwork.forward{ nodeId?: string }全部停止结果
forward.getStats只读{}转发统计
宿主 API参数返回
storage.get{ key: string }JSON 值或 null
storage.set{ key: string, value: unknown }单向写入
storage.remove{ key: string }单向删除
secrets.get{ key: string }凭据字符串或 null
secrets.getMany{ keys: string[] }key 到凭据/null 的映射
secrets.set{ key: string, value: string }空值表示删除
secrets.has{ key: string }boolean
secrets.delete{ key: string }删除结果
sync.listSavedConnections{}保存连接快照
sync.refreshSavedConnections{}刷新后的保存连接快照
sync.exportSavedConnectionsSnapshot{}保存连接同步快照
sync.applySavedConnectionsSnapshot`{ snapshot: object, conflictStrategy?: ‘rename''skip'
sync.getLocalSyncMetadata{}revision 元数据
sync.preflightExport{ connectionIds?: string[], embedKeys?: boolean, includeManagedKeys?: boolean }导出预检查结果。插件驱动的同步默认不包含托管密钥。
sync.exportOxide{ connectionIds?: string[], password: string, embedKeys?: boolean, includeManagedKeys?: boolean, includeManagedKeyPassphrases?: boolean, includeAppSettings?: boolean, selectedAppSettingsSections?: string[], includePluginSettings?: boolean, selectedPluginIds?: string[], progressRegistrationId?: string }.oxide 字节/元数据结果。插件驱动的同步默认不包含托管密钥。
sync.validateOxide{ fileData: number[] }.oxide 元数据
sync.previewImport{ fileData: number[], password: string, conflictStrategy?: string, progressRegistrationId?: string }导入预览
sync.importOxide{ fileData: number[], password: string, conflictStrategy?: string, progressRegistrationId?: string, selectedNames?: string[], selectedForwardIds?: string[], importForwards?: boolean, importPortableSecrets?: boolean, importAppSettings?: boolean, selectedAppSettingsSections?: string[], importPluginSettings?: boolean, selectedPluginIds?: string[], importQuickCommands?: boolean }导入结果
宿主 API参数返回
ide.isOpen{}boolean
ide.getProject{}项目快照或 null
ide.getOpenFiles{}打开文件快照
ide.getActiveFile{}活跃文件快照或 null
ai.getConversations{}已脱敏对话摘要
ai.getMessages{ conversationId: string }已脱敏消息
ai.getActiveProvider{}活跃供应商摘要或 null
ai.getAvailableModels{}模型名称
profiler.getMetrics{ nodeId: string }指标快照或 null
profiler.getHistory{ nodeId: string, limit?: number }指标历史
profiler.isRunning{ nodeId: string }boolean

通过 event-subscription 注册订阅,可以直接提供 event,也可以提供 Tauri 风格的 namespace + method

Namespace + method事件 key
app.onThemeChangeapp.themeChanged
app.onSettingsChangeapp.settingsChanged
i18n.onLanguageChangei18n.languageChanged
settings.onChangesettings.changed
ui.onLayoutChangeui.layoutChanged
sessions.onTreeChangesessions.treeChanged
sessions.onNodeStateChangesessions.nodeStateChanged
eventLog.onEntryeventLog.entry
forward.onSavedForwardsChangeforward.savedForwardsChanged
transfers.onProgresstransfers.progress
transfers.onCompletetransfers.complete
transfers.onErrortransfers.error
profiler.onMetricsprofiler.metrics
ide.onFileOpenide.fileOpen
ide.onFileCloseide.fileClose
ide.onActiveFileChangeide.activeFileChanged
ai.onMessageai.message
events.onConnectlifecycle.onConnect
events.onDisconnectlifecycle.onDisconnect
events.onLinkDownlifecycle.onLinkDown
events.onReconnectlifecycle.onReconnect

自定义插件事件走 events.on / events.emit 路径,并按插件 id 作用域隔离,避免插件抢占任意全局事件名。

先从插件管理器开始:

  • 检查插件是 readydisabledlegacy-jserror 还是 auto-disabled
  • 调试运行时代码前,先检查清单校验错误。
  • 进程诊断文本写 stderr。
  • stdout 严格保持 JSON Lines 协议。
  • 开发时一次只注册一个贡献。
  • 确认 registrationId 在插件内稳定且唯一。
  • 确认每个宿主调用都已声明并被允许。
  • 使用 oxideterm plugins list --json 检查无界面状态。
  • 使用 oxideterm plugins settings export --json 检查插件设置。

如果进程插件已激活但界面没有出现,检查两侧:

  • contributes.tabscontributes.sidebarPanels 声明了页面 id。
  • 运行时注册使用相同的 tabIdpanelId
  • 界面结构的 kindform
  • 界面结构至少包含一个 section 或顶层控件。
  • 需要 id 的控件已经设置 id

不要直接复制 Tauri 插件的 main.js

Tauri 概念Native 替代
main.js ESM activate(ctx)使用 JSON 协议的进程/WASM 运行时
React 标签页/侧边栏组件声明式 Native UI 结构
CSS 文件宿主渲染控件和主题
window.__OXIDE__宿主 API 调用和协议帧
直接 JS 事件总线event-subscription 注册和 sendEvent 请求
Tauri 命令导入清单声明的宿主 API 调用
浏览器 asset URL未来宿主持有的资源句柄

尽量保留公共插件意图、清单 id、设置 id 和工具元数据。把浏览器实现细节替换为 Native 进程/WASM 协议行为。