Skip to content

Plugin Development Guide

Applies to the current OxideTerm version (Plugin API v3)

The OxideTerm plugin system follows these design principles:

  • Runtime dynamic loading: Plugins are loaded at runtime as ESM packages through Blob URL + dynamic import(), without recompiling the host application
  • Membrane Pattern isolation: Plugins communicate with the host through a PluginContext frozen with Object.freeze(), and all API objects are immutable
  • Declarative Manifest: Plugin capabilities, including tabs, sidebar panels, and terminal hooks, must be declared in advance in plugin.json and are enforced at runtime
  • Fail-Open: Exceptions in Terminal hooks do not block terminal I/O and instead fall back to the original data
  • Automatic cleanup: Automatic resource cleanup based on the Disposable pattern; everything a plugin registers is automatically removed when the plugin unloads

The current PluginContext also includes two officially named namespaces for sync-oriented plugins: ctx.sync (encrypted export/import of saved connections plus conflict strategies) and ctx.secrets (plugin-scoped secure storage in the OS keychain). This means sync plugins for WebDAV, iCloud, or Syncthing no longer need to abuse ctx.storage or directly call host commands that have not been wrapped.

When a plugin needs to read multiple secrets in a single operation, prefer ctx.secrets.getMany(keys) over repeatedly calling ctx.secrets.get(). The host will try to combine those reads into a single keychain unlock flow, avoiding repeated Touch ID or system authentication prompts on macOS.

ctx.sync.importOxide() now supports four strategies: rename, skip, replace, and merge. The merge strategy is suitable for multi-device sync: the host preserves the existing connection ID and local metadata, updates the main connection fields from the imported side, unions tags, and continues reusing locally saved password / key passphrase / certificate passphrase values when they are missing from the import. Port forwarding rules in .oxide are also imported and exported as owner-bound saved forwards, but importing them does not directly create active forwards. In addition to the connections themselves, .oxide can now carry a snapshot of global OxideTerm settings and a declarative snapshot of plugin settings preferences. During export, plugins can use ctx.sync.exportOxide({ includeAppSettings: true, selectedAppSettingsSections: ['general', 'appearance'], includePluginSettings: true, includeLocalTerminalEnvVars: false }) to precisely control which host settings sections are packaged and whether local terminal environment variables are included. Correspondingly, ctx.sync.importOxide() also supports selectedAppSettingsSections, so only part of the settings snapshot can be imported. ctx.sync.previewImport() returns hasAppSettings, appSettingsSections, pluginSettingsCount, pluginSettingsByPlugin, forwardDetails, and record-level records, allowing plugins to render “why this will be renamed / skipped / replaced / merged” directly and to warn users in advance which global settings, plugin preferences, and saved forwards the snapshot will restore.

ctx.sync.getLocalSyncMetadata() now returns not only the overall savedConnectionsRevision, savedForwardsRevision, and settingsRevision, but also appSettingsSectionRevisions and pluginSettingsRevisions. Sync plugins can use these revision maps for per-section / per-plugin dirty checks and incremental uploads instead of reading the host’s internal stores or localStorage directly.

┌──────────────────────────────────────────────────────────────────┐
│ OxideTerm Host Application │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────┐ │
│ │ Rust Backend │ │ Tauri IPC │ │ React Frontend │ │
│ │ │ │ Control │ │ │ │
│ │ plugin.rs │←→│ Plane │←→│ ┌───────────────────┐ │ │
│ │ - list │ │ │ │ │ pluginStore │ │ │
│ │ - read_file │ │ │ │ │ (Zustand) │ │ │
│ │ - config │ │ │ │ └───────┬───────────┘ │ │
│ └─────────────┘ └──────────────┘ │ │ │ │
│ │ ┌───────▼───────────┐ │ │
│ │ │ pluginLoader │ │ │
│ │ │ - discover │ │ │
│ │ │ - validate │ │ │
│ │ │ - load / unload │ │ │
│ │ └───────┬───────────┘ │ │
│ │ │ │ │
│ │ ┌───────▼───────────┐ │ │
│ │ │ Context Factory │ │ │
│ │ │ (buildPluginCtx) │ │ │
│ │ │ → Object.freeze │ │ │
│ │ └───────┬───────────┘ │ │
│ │ │ │ │
│ └──────────┼──────────────┘ │
│ │ │
│ ┌──────────────────────────────────▼────────────┐ │
│ │ Plugin (ESM) │ │
│ │ │ │
│ │ activate(ctx) ←── PluginContext (frozen) │ │
│ │ ctx.connections ctx.events ctx.ui │ │
│ │ ctx.terminal ctx.settings ctx.i18n │ │
│ │ ctx.storage ctx.api ctx.assets │ │
│ │ ctx.sftp ctx.forward │ │
│ │ ctx.sessions ctx.transfers ctx.profiler │ │
│ │ ctx.eventLog ctx.ide ctx.ai ctx.app │ │
│ │ │ │
│ │ window.__OXIDE__ │ │
│ │ React · ReactDOM · zustand · lucideIcons │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘

Key points:

  1. Plugins and the host run in the same JS context (not in an iframe or WebWorker)
  2. React instances are shared through window.__OXIDE__ to ensure hook compatibility
  3. The Rust backend is responsible for file I/O, with path traversal protection, while the frontend manages lifecycle
  4. The Event Bridge forwards connection state changes in appStore as plugin events
LayerMechanismDescription
Membrane isolationObject.freeze()All API objects are immutable and non-extensible
Manifest declarationRuntime validationRegistering undeclared tabs/panels/hooks/commands throws an exception
Path protectionRust validate_plugin_id() + validate_relative_path() + canonicalizePrevents path traversal attacks
API whitelistcontributes.apiCommandsRestricts which Tauri commands the plugin can call (Advisory)
Circuit breaker10 errors / 60 seconds → auto-disablePrevents a faulty plugin from dragging down the system
Time budgetTerminal hooks 5ms budgetTimeouts count toward the circuit breaker

  • Developing OxideTerm plugins does not require additional build tooling
  • Plugins are plain ESM JavaScript files that OxideTerm imports dynamically
  • If you want to use TypeScript, compile it to ESM yourself; the project provides a standalone type definition file plugin-api.d.ts (see 20. Type Reference)
  • If you need bundling from multiple files into a single file, you can use esbuild or rollup with format: 'esm'
Section titled “Method 1: Create through Plugin Manager (Recommended)”
  1. Open Plugin Manager in OxideTerm, using the 🧩 icon in the sidebar
  2. Click the New Plugin button in the upper-right corner, marked with a + icon
  3. Enter a plugin ID, using lowercase letters, digits, and hyphens, such as my-first-plugin, and a display name
  4. Click Create
  5. OxideTerm will automatically generate a complete plugin scaffold under ~/.oxideterm/plugins/:
    • plugin.json — a prefilled manifest file
    • main.js — a Hello World template with activate() and deactivate()
  6. After creation, the plugin is automatically registered in Plugin Manager. Click Reload to load it

Step 1: Create the plugin directory

Terminal window
mkdir -p ~/.oxideterm/plugins/my-first-plugin
cd ~/.oxideterm/plugins/my-first-plugin

The plugin directory name does not need to match the id in plugin.json, but keeping them the same is recommended for easier management.

Step 2: Write plugin.json

{
"id": "my-first-plugin",
"name": "My First Plugin",
"version": "0.1.0",
"description": "A minimal OxideTerm plugin",
"author": "Your Name",
"main": "./main.js",
"engines": {
"oxideterm": ">=1.6.0"
},
"contributes": {
"tabs": [
{
"id": "hello",
"title": "Hello World",
"icon": "Smile"
}
]
}
}

Step 3: Write main.js

// Get React from the host (you must use the host's React instance)
const { React } = window.__OXIDE__;
const { createElement: h, useState } = React;
// Tab component
function HelloTab({ tabId, pluginId }) {
const [count, setCount] = useState(0);
return h('div', { className: 'p-6' },
h('h1', { className: 'text-xl font-bold text-foreground mb-4' },
'Hello from Plugin! 🧩'
),
h('p', { className: 'text-muted-foreground mb-4' },
`Plugin: ${pluginId} | Tab: ${tabId}`
),
h('button', {
onClick: () => setCount(c => c + 1),
className: 'px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90',
}, `Clicked ${count} times`),
);
}
// Activation entry point
export function activate(ctx) {
console.log(`[MyPlugin] Activating (id: ${ctx.pluginId})`);
ctx.ui.registerTabView('hello', HelloTab);
ctx.ui.showToast({ title: 'My Plugin Activated!', variant: 'success' });
}
// Deactivation entry point (optional)
export function deactivate() {
console.log('[MyPlugin] Deactivating');
}

Method 1: Manual installation (development mode)

  1. Make sure the plugin files are placed under ~/.oxideterm/plugins/my-first-plugin/
  2. Open Plugin Manager in OxideTerm, using the 🧩 icon in the sidebar
  3. Click Refresh to scan for new plugins
  4. The plugin will be loaded automatically and appear in the list
  5. You can see the plugin’s Tab icon in the sidebar; click it to open the Tab

Uninstall a plugin

  1. Find the plugin you want to uninstall in the Installed tab
  2. Click the 🗑️ button on the right side of the plugin row
  3. The plugin will be deactivated and deleted from disk

Debugging tips:

  • Open DevTools (Cmd+Shift+I / Ctrl+Shift+I) to inspect console.log output
  • If plugin loading fails, Plugin Manager shows a red error state together with actionable error messages such as “activate() must resolve within 5s” or “ensure your main.js exports an activate() function”
  • Each plugin in the Plugin Manager list includes a log viewer (📜 icon) that shows activation, unload, error, and other lifecycle logs in real time without opening DevTools
  • After editing code, click the plugin’s Reload button in Plugin Manager for hot reload

v1 single-file bundle (default):

~/.oxideterm/plugins/
└── your-plugin-id/
├── plugin.json # Required: plugin manifest
├── main.js # Required: ESM entry, specified by manifest.main
├── locales/ # Optional: i18n translation files
│ ├── en.json
│ ├── zh-CN.json
│ ├── ja.json
│ └── ...
└── assets/ # Optional: other asset files
└── ...

v2 multi-file package (format: "package"):

~/.oxideterm/plugins/
└── your-plugin-id/
├── plugin.json # Required: manifestVersion: 2, format: "package"
├── src/
│ ├── main.js # ESM entry, supports relative imports between modules
│ ├── components/
│ │ ├── Dashboard.js
│ │ └── Charts.js
│ └── utils/
│ └── helpers.js
├── styles/
│ ├── main.css # Automatically loaded when declared in manifest.styles
│ └── charts.css
├── assets/
│ ├── logo.png # Accessed through ctx.assets.getAssetUrl()
│ └── config.json
└── locales/
├── en.json
└── zh-CN.json

A v2 multi-file package is loaded through the built-in local HTTP file server (127.0.0.1, OS-assigned port), which supports standard ES Module import syntax between files.

Path constraints:

  • All file paths are relative to the plugin root
  • .. path traversal is forbidden
  • Absolute paths are forbidden
  • Plugin IDs must not contain /, \, .., or control characters
  • The Rust backend runs canonicalize() on resolved paths to ensure they never escape the plugin directory

This is the core descriptor file of a plugin. OxideTerm discovers plugins by scanning ~/.oxideterm/plugins/*/plugin.json.

{
"id": "your-plugin-id",
"name": "Human Readable Name",
"version": "1.0.0",
"description": "What this plugin does",
"author": "Your Name",
"main": "./main.js",
"engines": {
"oxideterm": ">=1.6.0"
},
"locales": "./locales",
"contributes": {
"tabs": [...],
"sidebarPanels": [...],
"settings": [...],
"terminalHooks": {...},
"terminalTransports": ["telnet"],
"connectionHooks": [...],
"apiCommands": [...]
}
}

The entry file must be a valid ES Module and export the following functions:

/**
* Required. Called when the plugin is activated.
* @param {PluginContext} ctx - Frozen API context object
*/
export function activate(ctx) {
// Register UI, hooks, event listeners, and so on
}
/**
* Optional. Called when the plugin unloads.
* Used to clean up global state (for example things attached to window).
* Note: anything registered through Disposable is cleaned up automatically.
*/
export function deactivate() {
// Clean up global references
}

Both functions may return a Promise for async activation/deactivation, but there is a 5-second timeout limit.

Loading mechanism (dual strategy):

v1 single-file bundle (default / format: "bundled"):

Rust read_plugin_file(id, "main.js")
→ byte array passed to the frontend
→ new Blob([bytes], { type: 'application/javascript' })
→ URL.createObjectURL(blob)
→ import(blobUrl)
→ module.activate(frozenContext)

When loaded through a Blob URL, a plugin cannot use relative-path import statements internally. Use a bundler such as esbuild or rollup to produce a single-file ESM bundle.

v2 multi-file package (format: "package"):

Frontend calls api.pluginStartServer()
→ Rust starts a local HTTP server (127.0.0.1:0)
→ returns an OS-assigned port
import(`http://127.0.0.1:{port}/plugins/{id}/src/main.js`)
→ browser standard ES Module loading
→ import './components/Dashboard.js' in main.js resolves automatically
→ module.activate(frozenContext)

A v2 package does support relative-path import statements between files. The browser resolves them automatically through the HTTP server. The server starts on first use and supports graceful shutdown.

Example v2 multi-file entry:

// src/main.js — import other modules from the same package
import { Dashboard } from './components/Dashboard.js';
import { formatBytes } from './utils/helpers.js';
export async function activate(ctx) {
// Dynamically load additional CSS
const cssDisposable = await ctx.assets.loadCSS('./styles/extra.css');
// Get a blob URL for an asset file (for <img> src, etc.)
const logoUrl = await ctx.assets.getAssetUrl('./assets/logo.png');
ctx.ui.registerTabView('dashboard', (props) => {
const { React } = window.__OXIDE__;
return React.createElement(Dashboard, { ...props, logoUrl });
});
}
export function deactivate() {
// Disposable automatically cleans up CSS and blob URLs
}

FieldTypeRequiredDescription
idstringUnique plugin identifier. May contain only letters, numbers, hyphens, and dots. /, \, .., and control characters are not allowed.
namestringHuman-readable plugin name
versionstringSemantic version, for example "1.0.0"
descriptionstringPlugin description
authorstringAuthor
mainstringRelative path to the ESM entry file, for example "./main.js" or "./src/main.js"
enginesobjectVersion compatibility requirements
engines.oxidetermstringRequired minimum OxideTerm version, for example ">=1.6.0". Currently only >=x.y.z and >x.y.z are supported; prerelease suffixes are compared by their base version.
contributesobjectDeclaration of the capabilities provided by the plugin
localesstringRelative path to the i18n translation directory, for example "./locales"

Additional fields for v2 packages:

FieldTypeRequiredDescription
manifestVersion1 | 2Manifest version, defaults to 1
format'bundled' | 'package'bundled (default) = single-file Blob URL loading; package = local HTTP server loading with relative imports
assetsstringRelative asset directory path, for example "./assets", used together with the ctx.assets API
stylesstring[]CSS file list such as ["./styles/main.css"]; automatically injected into <head> when loaded
sharedDependenciesRecord<string, string>Declares host-shared dependency versions. Currently supported: react, react-dom, zustand, lucide-react
repositorystringSource repository URL
checksumstringSHA-256 checksum used for integrity verification

Example v2 manifest:

{
"id": "com.example.multi-file-plugin",
"name": "Multi-File Plugin",
"version": "2.0.0",
"main": "./src/main.js",
"engines": { "oxideterm": ">=1.6.2" },
"manifestVersion": 2,
"format": "package",
"styles": ["./styles/main.css"],
"sharedDependencies": {
"react": "^18.0.0",
"lucide-react": "^0.300.0"
},
"contributes": {
"tabs": [{ "id": "dashboard", "title": "Dashboard", "icon": "LayoutDashboard" }]
},
"locales": "./locales"
}

Declares the Tab views provided by the plugin.

"tabs": [
{
"id": "dashboard",
"title": "Plugin Dashboard",
"icon": "LayoutDashboard"
}
]
FieldTypeDescription
idstringTab identifier, unique within the plugin
titlestringTab title shown in the tab bar
iconstringLucide React icon name

After declaring it, you still need to register the component in activate() by calling ctx.ui.registerTabView(id, Component).

The icon field is used directly for tab bar icon rendering. Use a PascalCase Lucide icon name such as "LayoutDashboard", "Server", or "Activity". If the name is invalid or missing, the system falls back to the Puzzle icon.

See the full icon list at: https://lucide.dev/icons/

Declares the sidebar panels provided by the plugin.

"sidebarPanels": [
{
"id": "quick-info",
"title": "Quick Info",
"icon": "Info",
"position": "bottom"
}
]
FieldTypeDescription
idstringPanel identifier, unique within the plugin
titlestringPanel title
iconstringLucide React icon name
position"top" | "bottom"Position inside the sidebar. Defaults to "bottom"

The icon field is used directly for activity bar icon rendering in the sidebar. Use a PascalCase Lucide icon name such as "Info", "Database", or "BarChart". If the name is invalid or missing, the system falls back to the Puzzle icon.

When there are many plugin panels, the middle area of the activity bar becomes scrollable automatically, while the fixed bottom buttons for local terminal, file manager, settings, and plugin manager remain visible.

Declares configurable plugin settings. Users can inspect and modify them in Plugin Manager.

"settings": [
{
"id": "greeting",
"type": "string",
"default": "Hello!",
"title": "Greeting Message",
"description": "The greeting shown in the dashboard"
},
{
"id": "enableFeature",
"type": "boolean",
"default": false,
"title": "Enable Feature",
"description": "Toggle this feature on or off"
},
{
"id": "theme",
"type": "select",
"default": "dark",
"title": "Theme",
"description": "Choose a color theme",
"options": [
{ "label": "Dark", "value": "dark" },
{ "label": "Light", "value": "light" },
{ "label": "System", "value": "system" }
]
},
{
"id": "maxItems",
"type": "number",
"default": 50,
"title": "Max Items",
"description": "Maximum number of items to display"
}
]
FieldTypeDescription
idstringSetting identifier
type"string" | "number" | "boolean" | "select"Value type
defaultanyDefault value
titlestringDisplay title
descriptionstring?Description
optionsArray<{ label, value }>?Used only when type: "select"

Declares terminal I/O interception capabilities.

"terminalHooks": {
"inputInterceptor": true,
"outputProcessor": true,
"shortcuts": [
{ "key": "ctrl+shift+d", "command": "openDashboard" },
{ "key": "ctrl+shift+s", "command": "saveBuffer" }
]
}
FieldTypeDescription
inputInterceptorboolean?Whether the plugin registers an input interceptor
outputProcessorboolean?Whether the plugin registers an output processor
shortcutsArray<{ key, command }>?Terminal-local keyboard shortcut declarations
shortcuts[].keystringKey combination such as "ctrl+shift+d"
shortcuts[].commandstringCommand name matched by registerShortcut()

Shortcut format:

  • Modifier keys: ctrl (on macOS, Ctrl/Cmd are both treated as ctrl), shift, alt
  • Letter keys: lowercase, such as d or s
  • Combine segments with +: ctrl+shift+d
  • Modifier order is normalized internally

Declares extra terminal transports that the plugin needs to open. Currently supported:

"terminalTransports": ["telnet"]
ValueDescription
"telnet"Allows the plugin to call ctx.terminal.openTelnet() and open a Telnet terminal tab

Telnet is a plaintext protocol intended for legacy devices, switches, serial servers, labs, and compatibility scenarios. Plugins should clearly warn users in their own UI that Telnet does not provide SSH-level encryption or host identity verification.

Calling ctx.terminal.openTelnet() without declaring terminalTransports: ["telnet"] throws.

Declares which connection lifecycle events the plugin cares about.

"connectionHooks": ["onConnect", "onDisconnect", "onReconnect", "onLinkDown"]

Supported values: "onConnect" | "onDisconnect" | "onReconnect" | "onLinkDown"

Note: this field currently serves only as documentation. Actual event subscription is done through methods such as ctx.events.onConnect().

Declares optional metadata for AI tools that a plugin provides or plans to expose to OxideSens. This field is a Tool Protocol v2 declaration layer: legacy plugins can omit it completely; new plugins can use it so the host can display capability, risk, target, approval, and structured-result semantics more clearly.

OxideSens now uses a target-first task orchestrator internally. Built-in chat does not expose every low-level host tool by default, and plugin tools are not automatically shown to the model unless the user explicitly invokes a plugin participant or the host enables the relevant capability path. The manifest API remains compatible; aiTools is metadata, not an execution permission.

"aiTools": [
{
"name": "router_backup",
"description": "Back up the running configuration from a network device.",
"parameters": {
"type": "object",
"required": ["targetId"],
"properties": {
"targetId": {
"type": "string",
"description": "Target terminal session or SSH node ID"
}
}
},
"capabilities": ["terminal.send", "terminal.observe", "filesystem.write"],
"risk": "write-file",
"targetKinds": ["terminal-session", "ssh-node"],
"resultSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"bytes": { "type": "number" }
}
}
}
]
FieldTypeDescription
namestringPlugin-local tool name. If exposed to OxideSens later, the host namespaces it to avoid conflicts with built-in tools.
descriptionstringShort explanation for both the model and the user.
parametersobject?Function-calling JSON Schema. Omitted means an empty object.
capabilitiesstring[]?Semantic capabilities such as filesystem.read, terminal.send, or state.list.
riskstring?Explicit risk level. If omitted, the host falls back to legacy inference.
targetKindsstring[]?Target kinds the tool can operate on, such as ssh-node, terminal-session, or sftp-session.
resultSchemaobject?JSON Schema for the envelope data field.

Available capabilities:

command.run, terminal.send, terminal.observe, terminal.wait, filesystem.read, filesystem.write, filesystem.search, navigation.open, state.list, network.forward, settings.read, settings.write, plugin.invoke, mcp.invoke

Available risk values:

read, write-file, execute-command, interactive-input, destructive, network-expose, settings-change, credential-sensitive

Available targetKinds:

local-shell, ssh-node, terminal-session, sftp-session, ide-workspace, app-tab, mcp-server, rag-index

Compatibility rules:

  • Plugins that omit aiTools continue to work as legacy plugins.
  • Declaring aiTools does not grant additional permissions and does not bypass user approval.
  • risk and capabilities only affect display, approval hints, and future plugin-tool registration semantics. Real host operations are still limited by PluginContext APIs and the apiCommands whitelist.
  • If a plugin returns a legacy string or object, the host displays it as a legacy result. If it returns a Tool Protocol v2 envelope, the tool UI prefers summary, data, warnings, and meta.targetId.

Recommended result shape:

return {
ok: true,
summary: 'Backed up router configuration to backups/router-1.cfg',
data: { path: 'backups/router-1.cfg', bytes: 42192 },
output: 'Saved 42192 bytes',
warnings: [],
meta: {
toolName: 'router_backup',
capability: 'filesystem.write',
targetId: 'terminal-session:abc123',
durationMs: 840
}
};

Declares the whitelist of backend Tauri commands that the plugin needs to call.

"apiCommands": ["list_sessions", "get_session_info"]

Only commands declared in this list can be called through ctx.api.invoke(). Calling an undeclared command throws and also logs a warning to the console.

CategoryCommandDescription
Connectionslist_connectionsList all active connections
get_connection_healthGet connection health metrics
quick_health_checkRun a quick connection check
SFTPnode_sftp_initInitialize an SFTP channel
node_sftp_list_dirList a remote directory
node_sftp_statGet file or directory metadata
node_sftp_previewPreview file contents
node_sftp_writeWrite a file
node_sftp_mkdirCreate a directory
node_sftp_deleteDelete a file
node_sftp_delete_recursiveRecursively delete a directory
node_sftp_renameRename or move a file
node_sftp_downloadDownload a file
node_sftp_uploadUpload a file
node_sftp_download_dirRecursively download a directory
node_sftp_upload_dirRecursively upload a directory
node_sftp_tar_probeProbe remote tar support
node_sftp_tar_uploadStream-upload through tar
node_sftp_tar_downloadStream-download through tar
Port Forwardinglist_port_forwardsList session port forwards
create_port_forwardCreate a port forward
stop_port_forwardStop a port forward
delete_port_forwardDelete a saved forwarding rule
restart_port_forwardRestart a port forward
update_port_forwardUpdate forwarding parameters
get_port_forward_statsGet forwarding traffic statistics
stop_all_forwardsStop all port forwards
Transfer Queuesftp_cancel_transferCancel a transfer
sftp_pause_transferPause a transfer
sftp_resume_transferResume a transfer
sftp_transfer_statsGet transfer queue statistics
Systemget_app_versionGet the OxideTerm version
get_system_infoGet system information
Networkplugin_http_requestIssue binary-safe HTTP requests through the host Rust backend, suitable for WebDAV, object storage, or other sync scenarios affected by CORS

Both the request body and response body for plugin_http_request are transferred as base64 so the plugin can safely handle non-text payloads. You still need to explicitly declare this command in contributes.apiCommands before using it.

Points to the relative path of the i18n translation directory.

"locales": "./locales"

See 11. Internationalization (i18n) for details.


When OxideTerm starts, or when the user clicks Refresh in Plugin Manager, the Rust backend scans ~/.oxideterm/plugins/:

list_plugins()
→ iterate each child directory under plugins/
→ look for plugin.json
→ serde parse into PluginManifest
→ validate required fields (id, name, main non-empty)
→ return Vec<PluginManifest>

Directories without plugin.json, or with parse failures, are skipped with a warning in the logs.

After the frontend receives the manifest in loadPlugin(), it performs a second round of validation:

  1. Required field check: id, name, version, and main must all be non-empty strings
  2. Version compatibility check: if engines.oxideterm is declared, it is compared against the current OxideTerm version with a simple semver comparison; currently only >= and > are supported, and prerelease suffixes are folded down to the base version
  3. If validation fails, the system sets state: 'error' and records the error information
loadPlugin(manifest)
1. setPluginState('loading')
2. api.pluginReadFile(id, mainPath) // Rust reads file bytes
3. new Blob([bytes]) → blobUrl // Create Blob URL
4. import(blobUrl) // Dynamic ESM import
5. URL.revokeObjectURL(blobUrl) // Reclaim Blob URL
6. Validate module.activate is a function
7. setPluginModule(id, module)
8. loadPluginLocales(id, ...) // Load i18n if declared
9. buildPluginContext(manifest) // Build frozen context
10. module.activate(ctx) // Call activate (5s timeout)
11. setPluginState('active')

Failure handling: if any step fails during loading, the system will:

  • Call store.cleanupPlugin(id) to clean up partial state
  • Call removePluginI18n(id) to clear i18n resources
  • Set state: 'error' and record the error message

activate(ctx) is the main entry point of a plugin. All registrations should be completed here:

export function activate(ctx) {
// 1. Register UI components
ctx.ui.registerTabView('myTab', MyTabComponent);
ctx.ui.registerSidebarPanel('myPanel', MyPanelComponent);
// 2. Register terminal hooks
ctx.terminal.registerInputInterceptor(myInterceptor);
ctx.terminal.registerOutputProcessor(myProcessor);
ctx.terminal.registerShortcut('myCommand', myHandler);
// 3. Subscribe to events
ctx.events.onConnect(handleConnect);
ctx.events.onDisconnect(handleDisconnect);
// 4. Read settings
const value = ctx.settings.get('myKey');
// 5. Read storage
const data = ctx.storage.get('myData');
}

Timeout: if activate() returns a Promise, it must resolve within 5000ms, otherwise loading is treated as failed.

After activation, the plugin enters runtime:

  • Registered Tab and Sidebar components are rendered through React
  • Terminal hooks are called synchronously on each terminal I/O event
  • Event handlers are triggered asynchronously on connection state changes via queueMicrotask()
  • Settings and storage reads/writes take effect immediately

Triggered when the user disables or reloads the plugin in Plugin Manager:

export function deactivate() {
// Clean up global state
delete window.__MY_PLUGIN_STATE__;
}

Timeout: if it returns a Promise, it must resolve within 5000ms.

Note: Anything registered through Disposable, including event listeners, UI components, and terminal hooks, does not need to be manually cleaned up in deactivate(). The system handles that automatically.

unloadPlugin(pluginId)
1. call module.deactivate() // 5s timeout
2. cleanupPlugin(pluginId) // Dispose all Disposables
3. removePluginI18n(pluginId) // Clear i18n resources
4. Close all Tabs owned by the plugin
5. Clear error trackers
6. setPluginState('inactive')
┌──────────┐
│ inactive │ ←── initial state / after unload
└────┬─────┘
│ loadPlugin()
┌────▼─────┐
│ loading │
└────┬─────┘
success / │ \ failure
┌────▼──┐ ┌──▼───┐
│ active │ │ error│
└────┬───┘ └──┬───┘
│ │ retryable
unload / │ ▼
disable │ ┌──────────┐
│ │ disabled │ ←── disabled manually or by circuit breaker
│ └──────────┘
┌──────────┐
│ inactive │
└──────────┘

PluginState enum values:

StateMeaning
'inactive'Not loaded / unloaded
'loading'Currently loading
'active'Activated and running normally
'error'An error occurred during load or runtime
'disabled'Disabled by the user or by the circuit breaker

PluginContext is the only argument passed to activate(ctx). It is a deeply frozen object containing 19 namespaces (pluginId + 18 child APIs). v3 adds 7 new read-only namespaces.

type PluginContext = Readonly<{
pluginId: string;
connections: PluginConnectionsAPI;
events: PluginEventsAPI;
ui: PluginUIAPI;
terminal: PluginTerminalAPI;
settings: PluginSettingsAPI;
i18n: PluginI18nAPI;
storage: PluginStorageAPI;
api: PluginBackendAPI;
assets: PluginAssetsAPI;
sftp: PluginSftpAPI;
forward: PluginForwardAPI;
// New namespaces added in v3
sessions: PluginSessionsAPI; // Session tree (read-only)
transfers: PluginTransfersAPI; // SFTP transfer monitoring
profiler: PluginProfilerAPI; // Resource monitoring
eventLog: PluginEventLogAPI; // Event log
ide: PluginIdeAPI; // IDE mode (read-only)
ai: PluginAiAPI; // AI conversations (read-only)
app: PluginAppAPI; // Application information
}>;
ctx.pluginId: string

The unique identifier of the current plugin, matching the id field in plugin.json.


Read-only connection state query API.

connections.getAll(): ReadonlyArray<ConnectionSnapshot>

Returns an immutable snapshot array of all SSH connections.

const conns = ctx.connections.getAll();
conns.forEach(c => {
console.log(`${c.username}@${c.host}:${c.port} [${c.state}]`);
});
connections.get(connectionId: string): ConnectionSnapshot | null

Returns a single connection snapshot by connection ID. Returns null if it does not exist.

connections.getState(connectionId: string): SshConnectionState | null

Quickly returns the current state of a connection. Returns null if it does not exist.

connections.getByNode(nodeId: string): ConnectionSnapshot | null

Resolves a stable nodeId back to its current connection snapshot. Returns null if the node does not exist or is not currently bound to a connection.

Possible state values: 'idle' | 'connecting' | 'active' | 'disconnecting' | 'disconnected' | 'reconnecting' | 'link_down' | { error: string }


Event subscription and publishing API. All on* methods return Disposable. Event handlers are invoked asynchronously through queueMicrotask() and do not block state updates.

events.onConnect(handler: (snapshot: ConnectionSnapshot) => void): Disposable

Triggered when a connection becomes 'active' (a new connection or recovery from a non-active state).

events.onDisconnect(handler: (snapshot: ConnectionSnapshot) => void): Disposable

Triggered when a connection enters the 'disconnected' or 'disconnecting' state, and also when the connection is removed.

events.onLinkDown(handler: (snapshot: ConnectionSnapshot) => void): Disposable

Triggered when a connection enters the 'reconnecting', 'link_down', or error state.

events.onReconnect(handler: (snapshot: ConnectionSnapshot) => void): Disposable

Triggered when a connection recovers from the 'reconnecting' / 'link_down' / error state back to 'active'.

ctx.events currently exposes only the 4 connection lifecycle events above. Additional node or session creation, close, or idle-state notifications are not part of the public plugin API.

If you need to track node or session-tree changes, use ctx.sessions instead:

ctx.sessions.getActiveNodes(): ReadonlyArray<{ nodeId: string; sessionId: string | null; connectionState: string }>
ctx.sessions.onTreeChange(handler: (tree: ReadonlyArray<SessionTreeNodeSnapshot>) => void): Disposable
ctx.sessions.onNodeStateChange(nodeId: string, handler: (state: string) => void): Disposable

Use onTreeChange() for node additions, removals, or tree-structure changes. Use onNodeStateChange() when you already know a nodeId and only need to react to that node’s connection-state transitions.

events.on(name: string, handler: (data: unknown) => void): Disposable

Listens for custom cross-plugin events. The event name is automatically prefixed with the namespace plugin:{pluginId}:{name}.

Note: You can only listen within your own plugin namespace. For cross-plugin communication, the receiver must use another agreed mechanism.

events.emit(name: string, data: unknown): void

Emits a custom event. The event name is prefixed with the same namespace automatically.

// Emit
ctx.events.emit('data-ready', { rows: 100 });
// Listen inside the same plugin
ctx.events.on('data-ready', (data) => {
console.log('Received:', data);
});

UI registration and interaction API.

ui.registerTabView(tabId: string, component: React.ComponentType<PluginTabProps>): Disposable

Registers a Tab view component. tabId must be declared in advance in contributes.tabs.

PluginTabProps:

type PluginTabProps = {
tabId: string; // Tab ID
pluginId: string; // Plugin ID
};
function MyTab({ tabId, pluginId }) {
return h('div', null, `Hello from ${pluginId}!`);
}
ctx.ui.registerTabView('myTab', MyTab);
ui.registerSidebarPanel(panelId: string, component: React.ComponentType): Disposable

Registers a sidebar panel component. panelId must be declared in advance in contributes.sidebarPanels.

Panel components do not receive props, unlike Tabs.

function MyPanel() {
return h('div', { className: 'p-2' }, 'Sidebar content');
}
ctx.ui.registerSidebarPanel('myPanel', MyPanel);

Registers a command in the global command palette (⌘K / Ctrl+K).

const disposable = ctx.ui.registerCommand('my-command', {
label: 'My Plugin Action',
icon: 'Zap',
shortcut: '⌘⇧P',
section: 'tools',
}, () => {
console.log('Command executed!');
});
// Unregister when no longer needed
disposable.dispose();

Commands are cleaned up automatically when the plugin unloads through the Disposable mechanism.

ui.openTab(tabId: string): void

Opens a Tab programmatically. If it is already open, focus switches to that Tab; otherwise a new Tab is created.

ctx.ui.openTab('dashboard');
ui.showToast(opts: {
title: string;
description?: string;
variant?: 'default' | 'success' | 'error' | 'warning';
}): void

Shows a toast notification.

ctx.ui.showToast({
title: 'File Saved',
description: 'config.json has been updated',
variant: 'success',
});
ui.showConfirm(opts: {
title: string;
description: string;
}): Promise<boolean>

Shows a confirmation dialog and returns the user’s choice. It is implemented with PluginConfirmDialog and matches the host application’s visual style.

const ok = await ctx.ui.showConfirm({
title: 'Delete Item?',
description: 'This action cannot be undone.',
});
if (ok) {
// Perform deletion
}
ui.registerContextMenu(target: ContextMenuTarget, items: ContextMenuItem[]): Disposable

Registers context menu items for a specific target area. target can be 'terminal', 'sftp', 'tab', or 'sidebar'.

The current host wiring is:

  • terminal: terminal content area
  • sftp: SFTP file panel context menu
  • tab: tab context menu
  • sidebar: sidebar host area context menu
ctx.ui.registerContextMenu('terminal', [
{
label: 'Run Analysis',
icon: 'BarChart',
handler: () => console.log('Analyzing...'),
},
{
label: 'Copy as Markdown',
handler: () => { /* ... */ },
when: () => ctx.terminal.getNodeSelection(currentNodeId) !== null,
},
]);
ui.registerStatusBarItem(options: StatusBarItemOptions): StatusBarHandle

Registers a status bar item and returns a handle that can update or dispose it.

type StatusBarItemOptions = {
text: string;
icon?: string;
tooltip?: string;
alignment: 'left' | 'right';
priority?: number;
onClick?: () => void;
};
type StatusBarHandle = {
update(options: Partial<StatusBarItemOptions>): void;
dispose(): void;
};
const status = ctx.ui.registerStatusBarItem({
text: '✔ Connected',
icon: 'Wifi',
alignment: 'right',
priority: 100,
onClick: () => ctx.ui.openTab('dashboard'),
});
// Update dynamically
status.update({ text: '⚠ Reconnecting...', icon: 'WifiOff' });
// Remove
status.dispose();

registerKeybinding(keybinding, handler) v3

Section titled “registerKeybinding(keybinding, handler) v3”
ui.registerKeybinding(keybinding: string, handler: () => void): Disposable

Registers a global keyboard shortcut. Unlike registerShortcut in Terminal Hooks, this does not need to be declared in the manifest.

The host handles these keys in the global shortcut dispatch path. Built-in shortcuts still take priority over plugin keybindings, and plugin keybindings take priority over terminal hook registerShortcut() handlers.

ctx.ui.registerKeybinding('ctrl+shift+p', () => {
console.log('Plugin action triggered!');
});
ui.showNotification(opts: {
title: string;
body?: string;
severity?: 'info' | 'warning' | 'error';
}): void

Shows a notification message, internally mapped to the toast system. It is similar to showToast, but provides a more semantic severity parameter.

ctx.ui.showNotification({
title: 'Transfer Complete',
body: '5 files uploaded successfully',
severity: 'info',
});
ui.showProgress(title: string): ProgressReporter

Shows a progress indicator and returns an updatable ProgressReporter.

The host displays a lightweight progress HUD in the upper-right corner. When report(value, total) reaches 100%, the progress item collapses automatically.

Note: the current ProgressReporter does not provide dispose() or a manual close API. If an operation fails or ends early, you should still proactively report a completed state once, for example progress.report(1, 1, 'Failed'); otherwise the HUD will remain visible.

type ProgressReporter = {
report(value: number, total: number, message?: string): void;
};
const progress = ctx.ui.showProgress('Deploying...');
progress.report(3, 10, 'Uploading files...');
progress.report(7, 10, 'Running scripts...');
progress.report(10, 10, 'Done!');
ui.getLayout(): Readonly<{
sidebarCollapsed: boolean;
activeTabId: string | null;
tabCount: number;
}>

Returns a read-only snapshot of the current layout state.

ui.onLayoutChange(handler: (layout: Readonly<{
sidebarCollapsed: boolean;
activeTabId: string | null;
tabCount: number;
}>) => void): Disposable

Subscribes to layout change events.

ctx.ui.onLayoutChange((layout) => {
console.log(`Sidebar: ${layout.sidebarCollapsed ? 'collapsed' : 'expanded'}`);
console.log(`Active tab: ${layout.activeTabId}`);
});

Terminal hooks and utility API.

terminal.registerInputInterceptor(handler: InputInterceptor): Disposable

Registers an input interceptor. It must be declared in the manifest as contributes.terminalHooks.inputInterceptor: true.

type InputInterceptor = (
data: string,
context: { sessionId: string },
) => string | null;

The interceptor runs synchronously on the terminal I/O hot path and has a 5ms time budget.

ctx.terminal.registerInputInterceptor((data, { sessionId }) => {
return data.toUpperCase();
});
ctx.terminal.registerInputInterceptor((data, ctx) => {
if (data.includes('dangerous-command')) {
return null;
}
return data;
});
terminal.registerOutputProcessor(handler: OutputProcessor): Disposable

Registers an output processor. It must be declared in the manifest as contributes.terminalHooks.outputProcessor: true.

type OutputProcessor = (
data: Uint8Array,
context: { sessionId: string },
) => Uint8Array;

It also runs synchronously on the hot path and has a 5ms time budget.

ctx.terminal.registerOutputProcessor((data, { sessionId }) => {
totalBytes += data.length;
return data;
});
terminal.registerShortcut(command: string, handler: () => void): Disposable

Registers a terminal shortcut. command must have a matching declaration in contributes.terminalHooks.shortcuts in the manifest.

ctx.terminal.registerShortcut('openDashboard', () => {
ctx.ui.openTab('dashboard');
});
terminal.getActiveTarget(): Readonly<{
sessionId: string;
terminalType: 'terminal' | 'local_terminal';
nodeId: string | null;
connectionId: string | null;
connectionState: string | null;
label: string | null;
}> | null

Returns the most recently focused terminal target. It covers both SSH terminals and local terminals, so a plugin can send commands back to the terminal the user interacted with most recently even from the plugin’s own Tab. connectionState === 'active' means the current target is writable. label is the host-provided best-effort display name, usually the host for SSH and usually the shell name for local terminals.

const target = ctx.terminal.getActiveTarget();
if (target) {
console.log(target.terminalType, target.label, target.sessionId);
}
terminal.writeToActive(text: string): boolean

Writes text directly to the most recently focused terminal. This is suitable for actions such as “send to current terminal” and supports both SSH and local terminals. Returns false if there is no target or if the target is not in the active state.

const ok = ctx.terminal.writeToActive('ls -la\n');
if (!ok) {
ctx.ui.showToast({ title: 'No active terminal', variant: 'warning' });
}
terminal.writeToNode(nodeId: string, text: string): void

Writes text to the terminal associated with a specific SSH node. nodeId remains stable across reconnects, so it is well suited to plugin logic bound to session tree nodes.

ctx.terminal.writeToNode(nodeId, 'journalctl -xe\n');
terminal.getNodeBuffer(nodeId: string): string | null

Returns the terminal buffer text content for the specified SSH node.

const buffer = ctx.terminal.getNodeBuffer(nodeId);
if (buffer) {
const lastLine = buffer.split('\n').pop();
console.log('Last line:', lastLine);
}
terminal.getNodeSelection(nodeId: string): string | null

Returns the text currently selected by the user in the terminal for the specified SSH node.

terminal.search(nodeId: string, query: string, options?: {
caseSensitive?: boolean;
regex?: boolean;
wholeWord?: boolean;
}): Promise<Readonly<{ matches: ReadonlyArray<unknown>; total_matches: number }>>

Searches text in the terminal buffer. It is executed through a backend Rust command and supports regex and case-sensitive options.

const result = await ctx.terminal.search(nodeId, 'error', {
caseSensitive: false,
regex: false,
});
console.log(`Found ${result.total_matches} matches`);

getScrollBuffer(nodeId, startLine, count) v3

Section titled “getScrollBuffer(nodeId, startLine, count) v3”
terminal.getScrollBuffer(nodeId: string, startLine: number, count: number):
Promise<ReadonlyArray<Readonly<{ text: string; lineNumber: number }>>>

Returns content from the scrollback buffer for the specified range of lines.

const lines = await ctx.terminal.getScrollBuffer(nodeId, 0, 100);
lines.forEach(l => console.log(`[${l.lineNumber}] ${l.text}`));
terminal.getBufferSize(nodeId: string):
Promise<Readonly<{ currentLines: number; totalLines: number; maxLines: number }>>

Returns terminal buffer size information.

const stats = await ctx.terminal.getBufferSize(nodeId);
console.log(`Buffer: ${stats.currentLines}/${stats.maxLines} lines`);
terminal.clearBuffer(nodeId: string): Promise<void>

Clears the terminal buffer for the specified node.

await ctx.terminal.clearBuffer(nodeId);
terminal.openTelnet(options: {
host: string;
port?: number;
cols?: number;
rows?: number;
}): Promise<{
sessionId: string;
info: LocalTerminalInfo;
}>

Opens a Telnet terminal tab. The plugin must first declare contributes.terminalTransports: ["telnet"] in its manifest; otherwise the call throws.

{
"contributes": {
"terminalTransports": ["telnet"]
}
}
await ctx.terminal.openTelnet({
host: '192.168.1.1',
port: 23,
});

The Telnet transport is handled by the Rust core, including TCP connection setup, Telnet IAC negotiation, NAWS resize, and terminal event forwarding. The plugin is responsible only for the entry point and UI. Telnet is plaintext, so plugins should warn users before connecting that it does not provide SSH encryption or host identity verification.


Plugin-scoped settings API, persisted to localStorage.

settings.get<T>(key: string): T

Returns a setting value. If the user has not configured a value, the default declared in the manifest is returned.

const greeting = ctx.settings.get('greeting');
const max = ctx.settings.get('maxItems');
settings.set<T>(key: string, value: T): void

Sets a value. This triggers listeners registered through onChange().

settings.onChange(key: string, handler: (newValue: unknown) => void): Disposable

Subscribes to setting changes.

ctx.settings.onChange('greeting', (newVal) => {
console.log('Greeting changed to:', newVal);
});
settings.exportSyncableSettings(): Promise<Readonly<{
revision: string;
exportedAt: string;
payload: SyncableSettingsPayload;
warnings: ReadonlyArray<SyncableSettingsWarning>;
}>>

Exports the host-whitelisted subset of this plugin’s settings so a sync plugin can package or upload them separately.

settings.applySyncableSettings(payload: SyncableSettingsPayload): Promise<Readonly<{
revision: string;
appliedPayload: SyncableSettingsPayload;
warnings: ReadonlyArray<SyncableSettingsWarning>;
}>>

Applies a syncable settings payload with host-side validation and normalization.

Storage key format: oxide-plugin-{pluginId}-setting-{settingId}


Plugin-scoped internationalization API.

i18n.t(key: string, params?: Record<string, string | number>): string

Translates the specified key. The key is automatically prefixed with plugin.{pluginId}..

const msg = ctx.i18n.t('greeting');
const hello = ctx.i18n.t('hello_user', { name: 'Alice' });

Corresponding translation file locales/en.json:

{
"greeting": "Welcome!",
"hello_user": "Hello, {{name}}!"
}
i18n.getLanguage(): string

Returns the current language code, such as "en" or "zh-CN".

i18n.onLanguageChange(handler: (lang: string) => void): Disposable

Subscribes to language changes.


Plugin-scoped persistent KV storage based on localStorage.

storage.get<T>(key: string): T | null

Returns a value. Returns null if it does not exist or if parsing fails. Values are automatically JSON-deserialized.

storage.set<T>(key: string, value: T): void

Stores a value. Values are automatically JSON-serialized.

storage.remove(key: string): void

Removes the specified key.

const count = (ctx.storage.get('launchCount') || 0) + 1;
ctx.storage.set('launchCount', count);

Storage key format: oxide-plugin-{pluginId}-{key}


Restricted Tauri backend command invocation API.

api.invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>

Invokes a Tauri backend command. The command must be declared in advance in contributes.apiCommands.

const sessions = await ctx.api.invoke('list_sessions');

Undeclared commands:

  • emit a warning in the console
  • throw Error: Command "xxx" not whitelisted in manifest contributes.apiCommands

Plugin asset file access API. Used to load CSS and obtain URLs for images, fonts, and data files.

assets.loadCSS(relativePath: string): Promise<Disposable>

Reads a CSS file in the plugin directory and injects a <style data-plugin="{pluginId}"> tag into <head>. Calling dispose() on the returned Disposable removes that <style> tag.

const cssDisposable = await ctx.assets.loadCSS('./styles/extra.css');
cssDisposable.dispose();

Note: CSS files declared in manifest.styles are automatically injected when the plugin loads, so you do not need to call loadCSS() manually. loadCSS() is intended for additional styles that are loaded on demand.

assets.getAssetUrl(relativePath: string): Promise<string>

Reads any file in the plugin directory and returns a blob URL, which can be used in <img src>, new Image(), and similar APIs.

const logoUrl = await ctx.assets.getAssetUrl('./assets/logo.png');
return h('img', { src: logoUrl, alt: 'Logo' });

Automatic MIME type detection:

ExtensionMIME
pngimage/png
jpg/jpegimage/jpeg
gifimage/gif
svgimage/svg+xml
webpimage/webp
woff/woff2font/woff / font/woff2
ttf/otffont/ttf / font/otf
jsonapplication/json
csstext/css
jsapplication/javascript
Otherapplication/octet-stream
assets.revokeAssetUrl(url: string): void

Manually releases a blob URL created through getAssetUrl() to free memory.

const url = await ctx.assets.getAssetUrl('./assets/large-image.png');
ctx.assets.revokeAssetUrl(url);

When the plugin unloads, all blob URLs that were not manually released and all injected <style> tags are cleaned up automatically.


Remote filesystem operation API. Operates on remote files through the SFTP protocol and does not need to be declared in contributes.apiCommands.

All methods use nodeId, a stable identifier that remains valid after reconnects. The backend initializes the SFTP channel automatically.

sftp.listDir(nodeId: string, path: string): Promise<ReadonlyArray<PluginFileInfo>>

Lists the contents of a remote directory. Returns a frozen array of file information.

const files = await ctx.sftp.listDir(nodeId, '/home/user');
for (const f of files) {
console.log(`${f.file_type} ${f.name} (${f.size} bytes)`);
}
sftp.stat(nodeId: string, path: string): Promise<PluginFileInfo>

Gets metadata for a remote file or directory.

sftp.readFile(nodeId: string, path: string): Promise<string>

Reads the content of a remote text file, up to 10 MB. Encoding is detected automatically and returned as a UTF-8 string. Throws for non-text files or files that exceed the size limit.

const content = await ctx.sftp.readFile(nodeId, '/etc/hostname');
sftp.writeFile(nodeId: string, path: string, content: string): Promise<void>

Writes text content to a remote file using atomic writes to avoid corruption.

sftp.mkdir(nodeId: string, path: string): Promise<void>

Creates a directory on the remote host.

sftp.delete(nodeId: string, path: string): Promise<void>

Deletes a remote file. To delete a directory recursively, use ctx.api.invoke('node_sftp_delete_recursive', { nodeId, path }).

sftp.rename(nodeId: string, oldPath: string, newPath: string): Promise<void>

Renames or moves a remote file or directory.

type PluginFileInfo = Readonly<{
name: string;
path: string;
file_type: 'file' | 'directory' | 'symlink' | 'unknown';
size: number;
modified: number | null;
permissions: string | null;
}>;

Port forwarding management API. Can be used to create, query, and manage SSH port forwarding without declaring anything in contributes.apiCommands.

Note: port forwarding uses sessionId rather than nodeId, because forwards are bound to the SSH session lifecycle. You can obtain the sessionId through ctx.connections.getByNode(nodeId)?.id.

forward.list(sessionId: string): Promise<ReadonlyArray<PluginForwardRule>>

Lists all active port forwards for a session.

const conn = ctx.connections.getByNode(nodeId);
if (conn) {
const forwards = await ctx.forward.list(conn.id);
forwards.forEach(f => console.log(`${f.forward_type} ${f.bind_address}:${f.bind_port}${f.target_host}:${f.target_port}`));
}
forward.create(request: PluginForwardRequest): Promise<{
success: boolean;
forward?: PluginForwardRule;
error?: string;
}>

Creates a new port forward. Supports local, remote, and dynamic (SOCKS5) forwarding.

const result = await ctx.forward.create({
sessionId: conn.id,
forwardType: 'local',
bindAddress: '127.0.0.1',
bindPort: 8080,
targetHost: 'localhost',
targetPort: 80,
description: 'My plugin forward',
});
if (result.success) {
console.log('Forward created:', result.forward?.id);
}
forward.stop(sessionId: string, forwardId: string): Promise<void>

Stops a port forward.

forward.stopAll(sessionId: string): Promise<void>

Stops all port forwards for a session.

forward.listSavedForwards(): ReadonlyArray<SavedForwardSnapshot>

Returns the current snapshot of saved forwards persisted by the host.

forward.onSavedForwardsChange(handler: (items: ReadonlyArray<SavedForwardSnapshot>) => void): Disposable

Subscribes to saved-forward snapshot updates.

exportSavedForwardsSnapshot() / applySavedForwardsSnapshot() v3

Section titled “exportSavedForwardsSnapshot() / applySavedForwardsSnapshot() v3”
forward.exportSavedForwardsSnapshot(): Promise<SavedForwardsSyncSnapshot>
forward.applySavedForwardsSnapshot(snapshot: SavedForwardsSyncSnapshot): Promise<ApplySavedForwardsSyncSnapshotResult>

Exports or applies the host-managed saved-forward sync snapshot.

forward.getStats(sessionId: string, forwardId: string): Promise<{
connectionCount: number;
activeConnections: number;
bytesSent: number;
bytesReceived: number;
} | null>

Gets traffic statistics for a port forward.

type PluginForwardRequest = {
sessionId: string;
forwardType: 'local' | 'remote' | 'dynamic';
bindAddress: string;
bindPort: number;
targetHost: string;
targetPort: number;
description?: string;
};
type PluginForwardRule = Readonly<{
id: string;
forward_type: 'local' | 'remote' | 'dynamic';
bind_address: string;
bind_port: number;
target_host: string;
target_port: number;
status: string;
description?: string;
}>;
type SavedForwardSnapshot = Readonly<{
id: string;
session_id: string;
owner_connection_id?: string;
forward_type: string;
bind_address: string;
bind_port: number;
target_host: string;
target_port: number;
auto_start: boolean;
created_at: string;
description?: string;
}>;

Complete example:

export async function activate(ctx) {
// 1. CSS declared in manifest.styles loads automatically (no code needed)
// 2. Load additional CSS on demand
const highlightCSS = await ctx.assets.loadCSS('./styles/highlight.css');
// 3. Get an image URL
const iconUrl = await ctx.assets.getAssetUrl('./assets/icon.svg');
// 4. Get JSON configuration
const configUrl = await ctx.assets.getAssetUrl('./assets/defaults.json');
const configResp = await fetch(configUrl);
const defaults = await configResp.json();
ctx.assets.revokeAssetUrl(configUrl);
ctx.ui.registerTabView('my-tab', (props) => {
const { React } = window.__OXIDE__;
return React.createElement('div', null,
React.createElement('img', { src: iconUrl, width: 32 }),
React.createElement('pre', null, JSON.stringify(defaults, null, 2)),
);
});
}

Read-only access API for the session tree. All data is provided as frozen snapshots.

sessions.getTree(): ReadonlyArray<SessionTreeNodeSnapshot>

Gets a frozen snapshot of the entire session tree.

type SessionTreeNodeSnapshot = Readonly<{
id: string;
label: string;
host?: string;
port?: number;
username?: string;
parentId: string | null;
childIds: readonly string[];
connectionState: string;
connectionId: string | null;
terminalIds: readonly string[];
sftpSessionId: string | null;
errorMessage?: string;
}>;
const tree = ctx.sessions.getTree();
tree.forEach(node => {
console.log(`${node.label} (${node.connectionState})`);
if (node.host) console.log(`${node.username}@${node.host}:${node.port}`);
});
sessions.getActiveNodes(): ReadonlyArray<Readonly<{
nodeId: string;
sessionId: string | null;
connectionState: string;
}>>

Gets a list of all active, connected nodes.

sessions.getNodeState(nodeId: string): string | null

Gets the connection state of a single node. Returns null if the node does not exist.

sessions.onTreeChange(handler: (tree: ReadonlyArray<SessionTreeNodeSnapshot>) => void): Disposable

Subscribes to session tree structure changes. Triggered when nodes are added or removed, or when connection state changes.

ctx.sessions.onTreeChange((tree) => {
const activeCount = tree.filter(n => n.connectionState === 'active').length;
status.update({ text: `${activeCount} active` });
});
sessions.onNodeStateChange(nodeId: string, handler: (state: string) => void): Disposable

Subscribes to state changes for a specific node.


SFTP transfer monitoring API. Read-only access. Progress events are throttled to 500ms intervals.

transfers.getAll(): ReadonlyArray<TransferSnapshot>

Gets all current transfer tasks.

type TransferSnapshot = Readonly<{
id: string;
nodeId: string;
name: string;
localPath: string;
remotePath: string;
direction: 'upload' | 'download';
size: number;
transferred: number;
state: 'pending' | 'active' | 'paused' | 'completed' | 'cancelled' | 'error';
error?: string;
startTime: number;
endTime?: number;
}>;
const transfers = ctx.transfers.getAll();
const active = transfers.filter(t => t.state === 'active');
console.log(`${active.length} active transfers`);
transfers.getByNode(nodeId: string): ReadonlyArray<TransferSnapshot>

Gets transfer tasks for a specific node.

transfers.onProgress(handler: (transfer: TransferSnapshot) => void): Disposable

Subscribes to transfer progress updates. Throttled to 500ms intervals to avoid high-frequency callbacks affecting performance.

ctx.transfers.onProgress((t) => {
const pct = Math.round((t.transferred / t.size) * 100);
console.log(`${t.name}: ${pct}%`);
});
transfers.onComplete(handler: (transfer: TransferSnapshot) => void): Disposable
transfers.onError(handler: (transfer: TransferSnapshot) => void): Disposable

Subscribes to transfer completion and error events.

ctx.transfers.onComplete((t) => {
ctx.ui.showToast({ title: `${t.name} uploaded`, variant: 'success' });
});
ctx.transfers.onError((t) => {
ctx.ui.showToast({ title: `${t.name} failed: ${t.error}`, variant: 'error' });
});

Resource monitoring API. Provides read-only access to system metrics such as CPU, memory, and network. Metrics are pushed with 1s throttling.

profiler.getMetrics(nodeId: string): ProfilerMetricsSnapshot | null

Gets the latest metrics snapshot for a node.

type ProfilerMetricsSnapshot = Readonly<{
timestampMs: number;
cpuPercent: number | null;
memoryUsed: number | null;
memoryTotal: number | null;
memoryPercent: number | null;
loadAvg1: number | null;
loadAvg5: number | null;
loadAvg15: number | null;
cpuCores: number | null;
netRxBytesPerSec: number | null;
netTxBytesPerSec: number | null;
sshRttMs: number | null;
}>;
const metrics = ctx.profiler.getMetrics(nodeId);
if (metrics) {
console.log(`CPU: ${metrics.cpuPercent}%, Mem: ${metrics.memoryPercent}%`);
}
profiler.getHistory(nodeId: string, maxPoints?: number): ReadonlyArray<ProfilerMetricsSnapshot>

Gets historical metrics. maxPoints limits the number of returned data points, starting from the newest.

profiler.isRunning(nodeId: string): boolean

Checks whether performance monitoring is currently running for the specified node.

profiler.onMetrics(nodeId: string, handler: (metrics: ProfilerMetricsSnapshot) => void): Disposable

Subscribes to real-time metric updates. Throttled to 1 second intervals.

ctx.profiler.onMetrics(nodeId, (m) => {
status.update({ text: `CPU ${m.cpuPercent?.toFixed(1)}%` });
});

Read-only access API for connection event logs.

eventLog.getEntries(filter?: {
severity?: 'info' | 'warn' | 'error';
category?: 'connection' | 'reconnect' | 'node';
}): ReadonlyArray<EventLogEntrySnapshot>

Gets event log entries, with optional filtering by severity and category.

type EventLogEntrySnapshot = Readonly<{
id: number;
timestamp: number;
severity: 'info' | 'warn' | 'error';
category: 'connection' | 'reconnect' | 'node';
nodeId?: string;
connectionId?: string;
title: string;
detail?: string;
source: string;
}>;
const errors = ctx.eventLog.getEntries({ severity: 'error' });
console.log(`${errors.length} errors in log`);
errors.forEach(e => {
console.log(`[${new Date(e.timestamp).toISOString()}] ${e.title}`);
});
eventLog.onEntry(handler: (entry: EventLogEntrySnapshot) => void): Disposable

Subscribes to new log entries.

ctx.eventLog.onEntry((entry) => {
if (entry.severity === 'error') {
ctx.ui.showNotification({
title: entry.title,
body: entry.detail,
severity: 'error',
});
}
});

Read-only access API for IDE mode. When OxideTerm’s built-in code editor based on CodeMirror is active, plugins can read project and file information.

ide.isOpen(): boolean

Checks whether IDE mode is active.

ide.getProject(): IdeProjectSnapshot | null

Gets information about the current project.

type IdeProjectSnapshot = Readonly<{
nodeId: string;
rootPath: string;
name: string;
isGitRepo: boolean;
gitBranch?: string;
}>;
const project = ctx.ide.getProject();
if (project) {
console.log(`Project: ${project.name} @ ${project.rootPath}`);
if (project.isGitRepo) console.log(`Branch: ${project.gitBranch}`);
}
ide.getOpenFiles(): ReadonlyArray<IdeFileSnapshot>

Gets the list of all open files.

type IdeFileSnapshot = Readonly<{
path: string;
name: string;
language: string;
isDirty: boolean;
isActive: boolean;
isPinned: boolean;
}>;
ide.getActiveFile(): IdeFileSnapshot | null

Gets the currently active file.

onFileOpen(handler) / onFileClose(handler)

Section titled “onFileOpen(handler) / onFileClose(handler)”
ide.onFileOpen(handler: (file: IdeFileSnapshot) => void): Disposable
ide.onFileClose(handler: (path: string) => void): Disposable

Subscribes to file open and close events.

ide.onActiveFileChange(handler: (file: IdeFileSnapshot | null) => void): Disposable

Subscribes to active file change events.

ctx.ide.onActiveFileChange((file) => {
if (file) {
console.log(`Now editing: ${file.name} (${file.language})`);
}
});

Read-only access API for AI conversations. Plugins can read conversation lists and messages, but cannot start conversations or send messages.

ai.getConversations(): ReadonlyArray<AiConversationSnapshot>

Gets summaries of all conversations.

type AiConversationSnapshot = Readonly<{
id: string;
title: string;
messageCount: number;
createdAt: number;
updatedAt: number;
}>;
ai.getMessages(conversationId: string): ReadonlyArray<AiMessageSnapshot>

Gets all messages in the specified conversation.

type AiMessageSnapshot = Readonly<{
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}>;
const convs = ctx.ai.getConversations();
if (convs.length > 0) {
const messages = ctx.ai.getMessages(convs[0].id);
console.log(`Latest conversation: ${convs[0].title} (${messages.length} messages)`);
}

getActiveProvider() / getAvailableModels()

Section titled “getActiveProvider() / getAvailableModels()”
ai.getActiveProvider(): Readonly<{ type: string; displayName: string }> | null
ai.getAvailableModels(): ReadonlyArray<string>

Gets information about the current AI provider and the list of available models.

const provider = ctx.ai.getActiveProvider();
if (provider) {
console.log(`AI Provider: ${provider.displayName} (${provider.type})`);
const models = ctx.ai.getAvailableModels();
console.log(`Available models: ${models.join(', ')}`);
}
ai.onMessage(handler: (info: Readonly<{
conversationId: string;
messageId: string;
role: string;
}>) => void): Disposable

Subscribes to new message events. Message content is not included; use getMessages() to retrieve it.


Application-level read-only information API. Provides global information such as theme, settings, platform, and version.

app.getTheme(): ThemeSnapshot

Gets the current theme information.

type ThemeSnapshot = Readonly<{
name: string;
isDark: boolean;
}>;
const theme = ctx.app.getTheme();
console.log(`Theme: ${theme.name} (${theme.isDark ? 'dark' : 'light'})`);
app.getSettings(category: 'terminal' | 'appearance' | 'general' | 'buffer' | 'sftp' | 'reconnect'):
Readonly<Record<string, unknown>>

Gets a read-only snapshot of application settings for the specified category.

const terminalSettings = ctx.app.getSettings('terminal');
console.log('Font size:', terminalSettings.fontSize);

getVersion() / getPlatform() / getLocale()

Section titled “getVersion() / getPlatform() / getLocale()”
app.getVersion(): string
app.getPlatform(): 'macos' | 'windows' | 'linux'
app.getLocale(): string
console.log(`OxideTerm ${ctx.app.getVersion()} on ${ctx.app.getPlatform()}`);
console.log(`Locale: ${ctx.app.getLocale()}`);
app.onThemeChange(handler: (theme: ThemeSnapshot) => void): Disposable

Subscribes to theme change events.

ctx.app.onThemeChange((theme) => {
console.log(`Theme changed to ${theme.name}`);
});
app.onSettingsChange(category: string, handler: (settings: Readonly<Record<string, unknown>>) => void): Disposable

Subscribes to changes for the specified settings category.

app.getPoolStats(): Promise<PoolStatsSnapshot>

Gets SSH connection pool statistics.

type PoolStatsSnapshot = Readonly<{
activeConnections: number;
totalSessions: number;
}>;
const stats = await ctx.app.getPoolStats();
console.log(`Pool: ${stats.activeConnections} connections, ${stats.totalSessions} sessions`);
app.refreshAfterExternalSync(options?: {
connections?: boolean;
savedForwards?: boolean;
settings?: boolean;
}): Promise<void>

Forces the host to refresh selected state after an external sync operation has modified saved connections, saved forwards, or settings outside the normal in-app mutation flow.

await ctx.app.refreshAfterExternalSync({
connections: true,
savedForwards: true,
settings: true,
});

Encrypted saved-connection sync API backed by .oxide import/export snapshots. Use this namespace when your plugin needs host-managed conflict resolution, revision tracking, and secure import/export flows.

listSavedConnections() / refreshSavedConnections()

Section titled “listSavedConnections() / refreshSavedConnections()”
sync.listSavedConnections(): ReadonlyArray<SavedConnectionSnapshot>
sync.refreshSavedConnections(): Promise<ReadonlyArray<SavedConnectionSnapshot>>

Returns the current saved-connection snapshot list. refreshSavedConnections() forces a fresh host read before returning the latest snapshot.

type SavedConnectionSnapshot = Readonly<{
id: string;
name: string;
group: string | null;
host: string;
port: number;
username: string;
auth_type: 'password' | 'key' | 'agent' | 'certificate';
key_path: string | null;
cert_path: string | null;
created_at: string;
last_used_at: string | null;
color: string | null;
tags: readonly string[];
agent_forwarding: boolean;
proxy_chain: readonly Readonly<{
host: string;
port: number;
username: string;
auth_type: 'password' | 'key' | 'agent' | 'certificate';
key_path?: string;
cert_path?: string;
agent_forwarding?: boolean;
}>[];
}>;

This snapshot is intentionally metadata-only. Plugins never receive passwords or key/certificate passphrases, but they do receive key paths, certificate paths, and proxy-chain topology so sync and audit plugins can understand certificate-auth hops without touching secrets.

sync.onSavedConnectionsChange(handler: (connections: ReadonlyArray<SavedConnectionSnapshot>) => void): Disposable

Subscribes to saved-connection snapshot updates.

ctx.sync.onSavedConnectionsChange((connections) => {
console.log(`Saved connections updated: ${connections.length}`);
});

exportSavedConnectionsSnapshot() / applySavedConnectionsSnapshot()

Section titled “exportSavedConnectionsSnapshot() / applySavedConnectionsSnapshot()”
sync.exportSavedConnectionsSnapshot(): Promise<SavedConnectionsSyncSnapshot>
sync.applySavedConnectionsSnapshot(
snapshot: SavedConnectionsSyncSnapshot,
options?: { conflictStrategy?: 'skip' | 'replace' | 'merge' },
): Promise<ApplySavedConnectionsSyncSnapshotResult>

Exports or applies a lightweight sync snapshot without packaging a full .oxide archive.

type SavedConnectionsSyncSnapshot = Readonly<{
revision: string;
exportedAt: string;
records: readonly SavedConnectionSyncRecord[];
}>;
type ApplySavedConnectionsSyncSnapshotResult = Readonly<{
applied: number;
skipped: number;
conflicts: number;
}>;
sync.getLocalSyncMetadata(): Promise<LocalSyncMetadata>

Returns host-maintained revision metadata so sync plugins can do dirty checks and incremental uploads.

type LocalSyncMetadata = Readonly<{
savedConnectionsRevision: string;
savedConnectionsUpdatedAt: string;
savedForwardsRevision?: string;
settingsRevision?: string;
appSettingsSectionRevisions?: Readonly<Partial<Record<OxideAppSettingsSectionId, string>>>;
pluginSettingsRevisions?: Readonly<Record<string, string>>;
}>;
sync.preflightExport(
connectionIds?: string[],
options?: { embedKeys?: boolean },
): Promise<ExportPreflightResult>

Checks whether a .oxide export can proceed before prompting for a password.

type ExportPreflightResult = Readonly<{
totalConnections: number;
missingKeys: readonly [string, string][];
connectionsWithKeys: number;
connectionsWithPasswords: number;
connectionsWithAgent: number;
totalKeyBytes: number;
canExport: boolean;
}>;
sync.exportOxide(request: {
connectionIds?: string[];
password: string;
description?: string;
embedKeys?: boolean;
includeAppSettings?: boolean;
selectedAppSettingsSections?: readonly OxideAppSettingsSectionId[];
includeLocalTerminalEnvVars?: boolean;
includePluginSettings?: boolean;
selectedPluginIds?: string[];
selectedForwardIds?: string[];
onProgress?: (progress: { stage: string; current: number; total: number }) => void;
}): Promise<Uint8Array>

Builds an encrypted .oxide archive. Besides saved connections, the export can optionally include app settings snapshots, plugin settings snapshots, and saved forwards.

Supported selectedAppSettingsSections values are currently: 'general', 'terminalAppearance', 'terminalBehavior', 'appearance', 'connections', 'fileAndEditor', and 'localTerminal'.

const archive = await ctx.sync.exportOxide({
password,
description: 'Nightly sync backup',
includeAppSettings: true,
selectedAppSettingsSections: ['general', 'appearance'],
includePluginSettings: true,
onProgress: (progress) => {
console.log(`Export ${progress.stage}: ${progress.current}/${progress.total}`);
},
});
sync.validateOxide(fileData: Uint8Array): Promise<OxideMetadata>

Reads archive metadata without importing it.

type OxideMetadata = Readonly<{
exported_at: string;
exported_by: string;
description?: string;
num_connections: number;
connection_names: readonly string[];
has_app_settings?: boolean;
plugin_settings_count?: number;
}>;

previewImport(fileData, password, options?)

Section titled “previewImport(fileData, password, options?)”
sync.previewImport(
fileData: Uint8Array,
password: string,
options?: {
conflictStrategy?: 'rename' | 'skip' | 'replace' | 'merge';
onProgress?: (progress: { stage: string; current: number; total: number }) => void;
},
): Promise<ImportPreview>

Generates an import preview so the plugin can explain rename, skip, replace, and merge decisions before applying anything.

type ImportPreview = Readonly<{
totalConnections: number;
unchanged: readonly string[];
willRename: readonly [string, string][];
willSkip: readonly string[];
willReplace: readonly string[];
willMerge: readonly string[];
hasEmbeddedKeys: boolean;
totalForwards: number;
hasAppSettings: boolean;
appSettingsFormat?: 'legacy' | 'sectioned';
appSettingsKeys?: readonly string[];
appSettingsPreview?: Readonly<Record<string, string>>;
appSettingsSections?: ReadonlyArray<Readonly<{
id: string;
fieldKeys: readonly string[];
fieldValues?: Readonly<Record<string, string>>;
containsEnvVars?: boolean;
}>>;
pluginSettingsCount: number;
pluginSettingsByPlugin: Readonly<Record<string, number>>;
forwardDetails: ReadonlyArray<Readonly<{
ownerConnectionName: string;
direction: 'local' | 'remote' | 'dynamic';
description: string;
}>>;
records: ReadonlyArray<Readonly<{
resource: 'connection';
name: string;
action: 'import' | 'rename' | 'skip' | 'replace' | 'merge';
reasonCode: 'new-connection' | 'name-conflict' | 'name-conflict-skipped' | 'replace-existing' | 'merge-existing';
targetName?: string;
targetConnectionId?: string;
forwardCount: number;
hasEmbeddedKeys: boolean;
}>>;
}>;
sync.importOxide(
fileData: Uint8Array,
password: string,
options?: {
selectedNames?: string[];
conflictStrategy?: 'rename' | 'skip' | 'replace' | 'merge';
importAppSettings?: boolean;
selectedAppSettingsSections?: readonly string[];
importPluginSettings?: boolean;
selectedPluginIds?: string[];
importForwards?: boolean;
onProgress?: (progress: { stage: string; current: number; total: number }) => void;
},
): Promise<ImportResult>

Imports a .oxide archive using host-managed conflict resolution. The merge strategy is designed for multi-device sync scenarios where local connection IDs and local-only secrets should be preserved where possible.

type ImportResult = Readonly<{
imported: number;
skipped: number;
merged: number;
replaced: number;
renamed: number;
errors: readonly string[];
renames: readonly [string, string][];
importedAppSettings: boolean;
skippedAppSettings: boolean;
importedPluginSettings: number;
skippedPluginSettings: boolean;
importedForwards: number;
skippedForwards: number;
}>;

Plugin-scoped secure storage backed by the OS keychain. Use this namespace for API tokens, credentials, refresh tokens, and any value that should not be persisted in ctx.storage.

secrets.get(key: string): Promise<string | null>

Returns the secret value for a key, or null if it does not exist.

secrets.getMany(keys: readonly string[]): Promise<Readonly<Record<string, string | null>>>

Fetches multiple secrets in a single call. Prefer this method when one user action needs several credentials, because the host can often collapse keychain unlocks into a single prompt.

const secrets = await ctx.secrets.getMany(['endpoint', 'username', 'token']);
console.log(secrets.endpoint, secrets.username);
secrets.set(key: string, value: string): Promise<void>

Stores or overwrites a secret value.

secrets.has(key: string): Promise<boolean>

Checks whether a secret exists without reading its value.

secrets.delete(key: string): Promise<void>

Removes the specified secret from the keychain.

if (!(await ctx.secrets.has('accessToken'))) {
await ctx.secrets.set('accessToken', token);
}

Secrets are namespaced per plugin. One plugin cannot read another plugin’s keychain entries through ctx.secrets.


Plugins must use the shared modules provided by the host instead of bundling their own copies of React or similar libraries. This guarantees React hook compatibility and avoids duplicate-instance problems.

window.__OXIDE__ = {
React: typeof import('react');
ReactDOM: { createRoot: typeof import('react-dom/client').createRoot };
zustand: { create: typeof import('zustand').create };
lucideIcons: Record<string, React.FC>; // Lucide icon name -> component mapping
clsx: typeof import('clsx').clsx; // Lightweight className builder
cn: (...inputs: ClassValue[]) => string; // Tailwind-merge + clsx
useTranslation: typeof import('react-i18next').useTranslation; // i18n hook
ui: PluginUIKit; // Plugin UI component library
};
const { React } = window.__OXIDE__;
const { createElement: h, useState, useEffect, useCallback, useRef, useMemo } = React;
// Use createElement instead of JSX
function MyComponent({ name }) {
const [count, setCount] = useState(0);
return h('div', null,
h('h1', null, `Hello ${name}!`),
h('button', { onClick: () => setCount(c => c + 1) }, `Count: ${count}`),
);
}

All React Hooks are available, including but not limited to:

  • useState / useReducer for state management
  • useEffect / useLayoutEffect for side effects
  • useCallback / useMemo for performance optimization
  • useRef for references
  • useContext for context values, if you create your own Context

Plugins can use the host’s Zustand instance to create their own stores:

const { zustand } = window.__OXIDE__;
const useMyStore = zustand.create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clearItems: () => set({ items: [] }),
}));
// Use inside a component
function ItemList() {
const { items, clearItems } = useMyStore();
return h('div', null,
h('ul', null, items.map((item, i) => h('li', { key: i }, item))),
h('button', { onClick: clearItems }, 'Clear'),
);
}
const { lucideIcons, lucideReact } = window.__OXIDE__;
// lucideIcons is a { name: component } mapping object
const Activity = lucideIcons['Activity'];
const Terminal = lucideIcons['Terminal'];
// lucideReact is the full module proxy with fallback; missing PascalCase icons fall back to Puzzle
const Wifi = lucideReact.Wifi;
function MyIcon() {
return h(Activity, { className: 'h-4 w-4 text-primary' });
}

See the full icon list at: https://lucide.dev/icons/

Manifest icon resolution: the contributes.tabs[].icon and contributes.sidebarPanels[].icon fields in plugin.json use icon-name strings such as "LayoutDashboard". The system resolves them automatically through resolvePluginIcon() into the corresponding Lucide React component for tab bar and sidebar activity bar rendering. Inside plugin components, use lucideIcons['IconName'] when indexing by string, or prefer lucideReact.IconName when you want automatic fallback behavior for missing icons.

OxideTerm provides a lightweight UI component library at window.__OXIDE__.ui that wraps OxideTerm’s theme system. Strongly prefer the UI Kit over hand-written Tailwind class names because it gives you:

  • Automatic adaptation to all themes, including dark, light, and custom themes
  • Protection against class name typos
  • Much less boilerplate code
  • Fewer plugin changes when the theme system evolves
const { React, lucideIcons, ui } = window.__OXIDE__;
const { createElement: h, useState } = React;
const Activity = lucideIcons['Activity'];
const Settings = lucideIcons['Settings'];
const Terminal = lucideIcons['Terminal'];

Component overview:

ComponentPurposeExample
ui.ScrollViewFull-height scroll container for Tab rootsh(ui.ScrollView, null, children)
ui.StackFlex layout, horizontal or verticalh(ui.Stack, { direction: 'horizontal', gap: 2 }, ...)
ui.GridGrid layouth(ui.Grid, { cols: 3, gap: 4 }, ...)
ui.CardCard with title and iconh(ui.Card, { icon: Activity, title: 'Stats' }, ...)
ui.StatNumeric stat cardh(ui.Stat, { icon: Hash, label: 'Input', value: 42 })
ui.ButtonButtonh(ui.Button, { variant: 'primary', onClick }, 'Click')
ui.InputText inputh(ui.Input, { value, onChange, placeholder: '...' })
ui.CheckboxCheckboxh(ui.Checkbox, { checked, onChange, label: 'Enable' })
ui.SelectDropdown selecth(ui.Select, { value, options, onChange })
ui.ToggleToggle controlh(ui.Toggle, { checked, onChange, label: 'Auto refresh' })
ui.TextSemantic texth(ui.Text, { variant: 'heading' }, 'Title')
ui.BadgeStatus badgeh(ui.Badge, { variant: 'success' }, 'Online')
ui.SeparatorDividerh(ui.Separator)
ui.IconTextIcon + text rowh(ui.IconText, { icon: Terminal }, 'Terminal')
ui.KVKey-value rowh(ui.KV, { label: 'Host' }, '192.168.1.1')
ui.EmptyStateEmpty-state placeholderh(ui.EmptyState, { icon: Inbox, title: 'No Data' })
ui.ListItemClickable list itemh(ui.ListItem, { icon: Server, title: 'prod-01', onClick })
ui.ProgressProgress barh(ui.Progress, { value: 75, variant: 'success' })
ui.AlertInfo / warning boxh(ui.Alert, { variant: 'warning', title: 'Attention' }, '...')
ui.SpinnerLoading indicatorh(ui.Spinner, { label: 'Loading...' })
ui.TableData tableh(ui.Table, { columns, data, onRowClick })
ui.CodeBlockCode or terminal outputh(ui.CodeBlock, null, 'ssh root@...')
ui.TabsTab switcherh(ui.Tabs, { tabs, activeTab, onTabChange }, content)
ui.HeaderPage-level header barh(ui.Header, { icon: Layout, title: 'Dashboard' })

Quick example — Tab component:

function MyTab({ tabId, pluginId }) {
const [count, setCount] = useState(0);
return h(ui.ScrollView, null,
h(ui.Header, {
icon: Activity,
title: 'My Plugin',
subtitle: `v1.0.0`,
}),
h(ui.Grid, { cols: 3, gap: 3 },
h(ui.Stat, { icon: Terminal, label: 'Sessions', value: 5 }),
h(ui.Stat, { icon: Activity, label: 'Traffic', value: '12 KB' }),
h(ui.Stat, { icon: Clock, label: 'Uptime', value: '2h' }),
),
h(ui.Card, { icon: Settings, title: 'Control Panel' },
h(ui.Stack, { gap: 2 },
h(ui.Text, { variant: 'muted' }, 'Click the button to increase the counter'),
h(ui.Stack, { direction: 'horizontal', gap: 2 },
h(ui.Button, { variant: 'primary', onClick: () => setCount(c => c + 1) }, `Count: ${count}`),
h(ui.Button, { variant: 'ghost', onClick: () => setCount(0) }, 'Reset'),
),
),
),
);
}

Quick example — Sidebar panel:

function MySidebar() {
return h(ui.Stack, { gap: 2, className: 'p-2' },
h(ui.Text, { variant: 'label' }, 'My Plugin'),
h(ui.KV, { label: 'Status', mono: true }, 'active'),
h(ui.KV, { label: 'Connections', mono: true }, '3'),
h(ui.Button, {
variant: 'outline',
size: 'sm',
className: 'w-full',
onClick: () => ctx.ui.openTab('myTab'),
}, 'Open Details'),
);
}

Tab components receive PluginTabProps:

// Recommended: use the UI Kit
function MyTabView({ tabId, pluginId }) {
return h(ui.ScrollView, null,
h(ui.Header, { icon: LayoutDashboard, title: 'My Plugin Tab' }),
h(ui.Card, { title: 'Content Area' },
h(ui.Text, { variant: 'body' }, 'This is a plugin Tab.'),
),
);
}

Pure createElement style (not recommended, but also supported):

function MyTabView({ tabId, pluginId }) {
return h('div', { className: 'h-full overflow-auto p-6' },
h('div', { className: 'max-w-4xl mx-auto' },
h('h1', { className: 'text-xl font-bold text-theme-text' }, 'My Plugin Tab'),
),
);
}

Registration inside activate():

ctx.ui.registerTabView('myTab', MyTabView);

Open a Tab:

ctx.ui.openTab('myTab');

Recommended Tab component structure:

function MyTab({ tabId, pluginId }) {
return h(ui.ScrollView, null,
h(ui.Header, {
icon: SomeIcon,
title: 'Title',
subtitle: 'Description',
}),
h(ui.Grid, { cols: 3, gap: 3 },
h(ui.Stat, { icon: Icon1, label: 'Metric', value: 42 }),
),
h(ui.Card, { icon: SomeIcon, title: 'Section' },
h(ui.Stack, { gap: 2 }, /* children */),
),
);
}

Sidebar panel components are function components without props:

// Recommended: use the UI Kit
function MyPanel() {
return h(ui.Stack, { gap: 2, className: 'p-2' },
h(ui.Text, { variant: 'label', className: 'px-1' }, 'My Panel'),
h(ui.KV, { label: 'Status', mono: true }, 'active'),
h(ui.KV, { label: 'Connections', mono: true }, '3'),
h(ui.Button, {
variant: 'outline', size: 'sm', className: 'w-full mt-1',
onClick: () => ctx.ui.openTab('myTab'),
}, 'Open in Tab'),
);
}

Pure createElement style:

function MyPanel() {
return h('div', { className: 'p-2 space-y-2' },
h('div', { className: 'text-xs font-semibold text-theme-text-muted uppercase tracking-wider px-1 mb-1' },
'My Panel'
),
);
}

Because sidebar space is limited, the recommended approach is:

  • use small text such as text-xs
  • keep layouts compact, such as p-2 and space-y-1
  • provide an Open in Tab button that links to a more detailed view

Below is the full API reference for all window.__OXIDE__.ui components.

ScrollView — standard root container for a Tab

PropTypeDefaultDescription
maxWidthstring'4xl'Tailwind max-width suffix
paddingstring'6'Tailwind padding suffix
classNamestringAdditional custom classes
h(ui.ScrollView, null, /* all Tab content */);
h(ui.ScrollView, { maxWidth: '6xl', padding: '4' }, children);

Stack — flex layout

PropTypeDefaultDescription
direction'vertical' | 'horizontal''vertical'Layout direction
gapnumber2Gap value (Tailwind gap scale)
align'start' | 'center' | 'end' | 'stretch' | 'baseline'Cross-axis alignment
justify'start' | 'center' | 'end' | 'between' | 'around'Main-axis alignment
wrapbooleanfalseWhether wrapping is enabled
h(ui.Stack, { direction: 'horizontal', gap: 2, align: 'center' },
h(ui.Button, null, 'A'),
h(ui.Button, null, 'B'),
);

Grid — grid layout

PropTypeDefaultDescription
colsnumber2Number of columns
gapnumber4Gap size
h(ui.Grid, { cols: 3, gap: 3 },
h(ui.Stat, { label: 'A', value: 1 }),
h(ui.Stat, { label: 'B', value: 2 }),
h(ui.Stat, { label: 'C', value: 3 }),
);

Card — theme-aware card

PropTypeDefaultDescription
titlestringCard title
iconReact.ComponentTypeLeading title icon, usually a Lucide component
headerRightReact.ReactNodeCustom content on the right side of the header
h(ui.Card, {
icon: Settings,
title: 'Settings',
headerRight: h(ui.Badge, { variant: 'info' }, 'v2'),
},
h(ui.Text, { variant: 'muted' }, 'Card content'),
);

Stat — numeric stat card

PropTypeDescription
labelstringDescriptive text
valuestring | numberDisplayed numeric or textual value
iconReact.ComponentTypeOptional icon
h(ui.Stat, { icon: Activity, label: 'Traffic', value: '12.5 KB' })

Button — button

PropTypeDefaultDescription
variant'primary' | 'secondary' | 'destructive' | 'ghost' | 'outline''secondary'Style variant
size'sm' | 'md' | 'lg' | 'icon''md'Size
disabledbooleanfalseDisabled state
onClickfunctionClick callback
h(ui.Button, { variant: 'primary', onClick: handler }, 'Save');
h(ui.Button, { variant: 'destructive', size: 'sm' }, 'Delete');
h(ui.Button, { variant: 'ghost', size: 'icon' }, h(Trash2, { className: 'h-4 w-4' }));

Input — text input

PropTypeDefaultDescription
value / defaultValuestringControlled or uncontrolled value
placeholderstringPlaceholder text
typestring'text'HTML input type
size'sm' | 'md''md'Size
onChangefunctionChange callback
onKeyDownfunctionKeyboard callback
h(ui.Input, {
value: text,
onChange: (e) => setText(e.target.value),
placeholder: 'Enter a search keyword...',
size: 'sm',
});

Checkbox — checkbox

PropTypeDescription
checkedbooleanChecked state
onChange(checked: boolean) => voidChange callback
labelstringOptional label
disabledbooleanDisabled state
h(ui.Checkbox, { checked: enabled, onChange: setEnabled, label: 'Enable feature' })

Select — dropdown select

PropTypeDescription
valuestring | numberCurrent value
options{ label: string, value: string | number }[]Option list
onChange(value: string) => voidChange callback
placeholderstringPlaceholder
size'sm' | 'md'Size
h(ui.Select, {
value: theme,
options: [
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' },
],
onChange: setTheme,
});

Text — semantic text

variantStyleTypical use
'heading'large bold textpage title
'subheading'smaller bold textsection title
'body'standard textparagraph content
'muted'subdued small textdescriptions / hints
'mono'monospace textIP addresses / code
'label'uppercase muted textsection label
'tiny'extra-small muted textsecondary metadata

You can change the rendered tag through the as prop, for example h(ui.Text, { variant: 'heading', as: 'h2' }, '...').

Badge — status badge

variantColorUse
'default'grayneutral state
'success'greensuccess / online
'warning'yellowwarning
'error'rederror / offline
'info'blueinformation / version
h(ui.Badge, { variant: 'success' }, 'Active')

KV — key-value row

h(ui.KV, { label: 'Host', mono: true }, '192.168.1.1')

Set mono: true to render the value in monospace.

IconText — icon + text

h(ui.IconText, { icon: Terminal }, 'Active Sessions')

Separator — divider

h(ui.Separator)

EmptyState — empty-state placeholder

h(ui.EmptyState, {
icon: Inbox,
title: 'No Data',
description: 'Add a new item to get started.',
action: h(ui.Button, { variant: 'primary' }, 'Add'),
})

ListItem — list row

h(ui.ListItem, {
icon: Server,
title: 'production-01',
subtitle: '[email protected]',
right: h(ui.Badge, { variant: 'success' }, 'Active'),
active: isSelected,
onClick: () => select(item),
})

Header — page header bar

h(ui.Header, {
icon: LayoutDashboard,
title: 'Dashboard',
subtitle: 'v1.0.0',
action: h(ui.Button, { size: 'sm' }, 'Refresh'),
})

Tabs — tab switcher

const [tab, setTab] = useState('overview');
h(ui.Tabs, {
tabs: [
{ id: 'overview', label: 'Overview', icon: Activity },
{ id: 'logs', label: 'Logs', icon: FileText },
],
activeTab: tab,
onTabChange: setTab,
},
tab === 'overview' ? h(OverviewPanel) : h(LogsPanel),
)
PropTypeDescription
tabs{ id: string, label: string, icon?: Component }[]Tab definition array
activeTabstringActive tab id
onTabChange(id: string) => voidTab change callback

Table — data table

h(ui.Table, {
columns: [
{ key: 'host', header: 'Host' },
{ key: 'port', header: 'Port', align: 'right', width: '80px' },
{ key: 'status', header: 'Status', render: (v) => h(ui.Badge, { variant: v === 'active' ? 'success' : 'error' }, v) },
],
data: connections,
striped: true,
onRowClick: (row) => select(row.id),
})
PropTypeDefaultDescription
columns{ key, header, width?, align?, render? }[]Column definitions
dataRecord<string, unknown>[]Data rows
compactbooleanfalseCompact row height
stripedbooleanfalseZebra striping
emptyTextstring'No data'Empty-state text
onRowClick(row, index) => voidRow click callback

Progress — progress bar

h(ui.Progress, { value: 75, max: 100, variant: 'success', showLabel: true })
variantColor
'default'theme accent color
'success'green
'warning'yellow
'error'red

Toggle — toggle control

h(ui.Toggle, { checked: autoRefresh, onChange: setAutoRefresh, label: 'Auto Refresh' })

Unlike a checkbox, Toggle uses a switch-style control and is better suited to on/off scenarios.

Alert — info / warning box

h(ui.Alert, { variant: 'warning', icon: AlertTriangle, title: 'Attention' },
'This action cannot be undone.',
)
variantColorUse
'info'blueinformation
'success'greensuccess
'warning'yellowwarning
'error'rederror

Spinner — loading indicator

h(ui.Spinner, { size: 'sm', label: 'Loading...' })

Available size values: 'sm' (16px), 'md' (24px), 'lg' (32px)

CodeBlock — code or terminal output

h(ui.CodeBlock, { maxHeight: '200px', wrap: true },
'ssh [email protected]\nPassword: ****\nWelcome to Ubuntu 22.04',
)
PropTypeDefaultDescription
maxHeightstring'300px'Max height with scroll overflow
wrapbooleanfalseWhether to soft-wrap lines

8.4 Theme CSS Variable Reference (Advanced)

Section titled “8.4 Theme CSS Variable Reference (Advanced)”

If you need custom styling beyond what the UI Kit covers, you can directly use OxideTerm’s semantic CSS classes.

Text colors:

ClassUse
text-theme-textprimary text
text-theme-text-mutedsecondary / muted text
text-theme-accentaccent text

Background colors:

ClassUse
bg-theme-bgpage background
bg-theme-bg-panelcard / panel background
bg-theme-bg-hoverhover highlight background
bg-theme-accentaccent background

Borders:

ClassUse
border-theme-borderstandard border

Because Tab and Sidebar components are rendered separately, they cannot communicate directly through React props. Recommended approaches:

Option 1: Zustand store (recommended)

const { zustand } = window.__OXIDE__;
const useMyStore = zustand.create((set) => ({
data: [],
setData: (data) => set({ data }),
}));
function MyTab() {
const { data } = useMyStore();
return h('div', null, `Items: ${data.length}`);
}
function MyPanel() {
const { data } = useMyStore();
return h('div', null, `Count: ${data.length}`);
}

Option 2: Global variable + captured ctx reference

// In activate()
window.__MY_PLUGIN_CTX__ = ctx;
// Inside components
function MyTab() {
const ctx = window.__MY_PLUGIN_CTX__;
const conns = ctx?.connections.getAll() ?? [];
// ...
}
// Cleanup in deactivate()
export function deactivate() {
delete window.__MY_PLUGIN_CTX__;
}

Input interceptors are called synchronously every time the user sends data to the terminal. They run directly on the terminal I/O hot path.

Call chain:

User input -> term.onData(data)
-> runInputPipeline(data, sessionId)
-> iterate all interceptors
-> interceptor(data, { sessionId })
-> return modified data or null
-> if result is not null -> send through WebSocket to the backend

Use cases:

  • input filtering and auditing
  • automatic prefix insertion
  • command interception and mistake prevention
  • input statistics
// Example: add an input prefix based on settings
ctx.terminal.registerInputInterceptor((data, { sessionId }) => {
const prefix = ctx.settings.get('inputPrefix');
if (prefix) return prefix + data;
return data;
});

Important notes:

  1. Interceptors are synchronous and do not support async
  2. Returning null fully suppresses the input so nothing is sent to the server
  3. Interceptors from multiple plugins are chained in registration order, where the previous output becomes the next input
  4. Exceptions are silently caught and the data is passed through unchanged (fail-open)
  5. There is a 5ms time budget; see 9.4

Output processors are called synchronously each time terminal data is received from the remote server.

Call chain:

WebSocket receives MSG_TYPE_DATA
-> runOutputPipeline(data, sessionId)
-> iterate all processors
-> processor(data, { sessionId })
-> return processed Uint8Array
-> write into xterm.js for rendering

Use cases:

  • output statistics and auditing
  • sensitive-data masking
  • output logging
ctx.terminal.registerOutputProcessor((data, { sessionId }) => {
// Count bytes
totalBytes += data.length;
// Pass raw data through unchanged
return data;
});

Notes:

  1. The input parameter is Uint8Array (raw bytes), not a string
  2. The return value must also be Uint8Array
  3. Like Input Interceptors, it has a 5ms time budget
  4. Fail-open on exceptions: if a processor throws, the previous step’s data is used

Registers keyboard shortcuts that are active while the terminal has focus.

Registration:

// manifest:
// "shortcuts": [{ "key": "ctrl+shift+d", "command": "openDashboard" }]
ctx.terminal.registerShortcut('openDashboard', () => {
ctx.ui.openTab('dashboard');
});

Shortcut matching flow:

Terminal keydown event
-> matchPluginShortcut(event)
-> build normalized key: parts.sort().join('+')
example: Ctrl+Shift+D -> "ctrl+d+shift"
-> look up in the shortcuts Map
-> if found -> call handler and prevent default behavior

Modifier key mapping:

  • event.ctrlKey || event.metaKey -> "ctrl" (on macOS, Cmd also counts as Ctrl)
  • event.shiftKey -> "shift"
  • event.altKey -> "alt"

9.4 Performance Budget and Circuit Breaker

Section titled “9.4 Performance Budget and Circuit Breaker”

Terminal hooks run on the terminal I/O hot path, so every keystroke or received data chunk triggers them synchronously. Because of that, the performance limits are strict:

Time budget: each hook invocation must complete within 5ms (HOOK_BUDGET_MS)

  • timeouts emit console.warn
  • timeouts count toward the circuit breaker error total

Circuit breaker: 10 errors / 60 seconds -> the plugin is automatically disabled

  • the counter resets after the 60-second window expires
  • once the circuit breaker trips, the plugin is unloaded immediately
  • the disabled state is persisted to plugin-config.json so it survives restarts

Best practices:

// Good: lightweight synchronous work
ctx.terminal.registerInputInterceptor((data) => {
counter++;
return data;
});
// Bad: expensive work
ctx.terminal.registerInputInterceptor((data) => {
// Do not perform large-text regex work, DOM operations, and so on here
const result = someExpensiveRegex.test(data);
return data;
});
// Good: defer heavy work to a microtask
ctx.terminal.registerOutputProcessor((data) => {
queueMicrotask(() => {
// Put heavy work here
processDataAsync(data);
});
return data;
});

OxideTerm’s Event Bridge turns connection-state changes in appStore into plugin-subscribeable events.

Event trigger conditions:

EventTrigger condition
connection:connectA new connection appears and its state is active; or a non-active state other than reconnecting / link_down / error changes to active
connection:reconnectState changes from reconnecting / link_down / error to active
connection:link_downEnters the reconnecting / link_down / error state
connection:disconnectEnters disconnected / disconnecting, or the connection is removed from the list

Example usage:

const disposable1 = ctx.events.onConnect((snapshot) => {
console.log(`Connected: ${snapshot.username}@${snapshot.host}`);
console.log(`State: ${snapshot.state}, Terminals: ${snapshot.terminalIds.length}`);
});
const disposable2 = ctx.events.onDisconnect((snapshot) => {
console.log(`Disconnected: ${snapshot.id}`);
});
const disposable3 = ctx.events.onLinkDown((snapshot) => {
ctx.ui.showToast({
title: 'Connection Lost',
description: `${snapshot.host} link down`,
variant: 'warning',
});
});
const disposable4 = ctx.events.onReconnect((snapshot) => {
ctx.ui.showToast({
title: 'Reconnected',
description: `${snapshot.host} is back`,
variant: 'success',
});
});
ctx.sessions.onTreeChange((tree) => {
console.log('Session tree updated, node count:', tree.length);
});
const activeNodes = ctx.sessions.getActiveNodes();
activeNodes.forEach(({ nodeId }) => {
ctx.sessions.onNodeStateChange(nodeId, (state) => {
console.log(`Node ${nodeId} changed state to ${state}`);
});
});

If you need to observe node additions, removals, or connection-state changes, build on the tree snapshots and node-state subscriptions exposed by ctx.sessions rather than relying on non-public internal event names.

// Plugin A: emit an event
ctx.events.emit('data-ready', { items: [...] });
// Plugin A: listen to its own event
ctx.events.on('data-ready', (data) => {
console.log('Received:', data.items.length);
});

Namespacing rules:

  • ctx.events.emit('foo', data) actually emits plugin:{pluginId}:foo
  • ctx.events.on('foo', handler) actually listens to plugin:{pluginId}:foo
  • emit and on inside the same plugin automatically match each other

Cross-plugin communication: in the current API design, every plugin’s on and emit automatically prepend that plugin’s own namespace. That means a plugin can only listen to its own events by default. Cross-plugin communication requires another mechanism, such as a shared store or an agreed event name through the lower-level bridge.

All connection-event handlers receive an immutable ConnectionSnapshot object:

type ConnectionSnapshot = Readonly<{
id: string;
host: string;
port: number;
username: string;
state: SshConnectionState;
refCount: number;
keepAlive: boolean;
createdAt: string;
lastActive: string;
terminalIds: readonly string[];
parentConnectionId?: string;
}>;

Possible values for SshConnectionState:

type SshConnectionState =
| 'idle'
| 'connecting'
| 'active'
| 'disconnecting'
| 'disconnected'
| 'reconnecting'
| 'link_down'
| { error: string };

v3 adds SFTP transfer-related events, exposed through the ctx.transfers API:

Event methodTrigger condition
transfers.onProgress(handler)Transfer progress updates, throttled to 500ms
transfers.onComplete(handler)Transfer completes
transfers.onError(handler)Transfer fails

All handlers receive a TransferSnapshot object; see 6.14.

ctx.transfers.onProgress((t) => {
const pct = ((t.transferred / t.size) * 100).toFixed(1);
console.log(`[${t.direction}] ${t.name}: ${pct}%`);
});
ctx.transfers.onComplete((t) => {
const duration = ((t.endTime - t.startTime) / 1000).toFixed(1);
console.log(`Done: ${t.name} in ${duration}s`);
});
ctx.transfers.onError((t) => {
console.error(`Failed: ${t.name}${t.error}`);
});

OxideTerm uses i18next as its i18n framework. Plugin translation resources are loaded into the main i18next instance through loadPluginI18n(), under the namespace plugin.{pluginId}.*.

your-plugin/
├── plugin.json <- "locales": "./locales"
└── locales/
├── en.json <- English (strongly recommended)
├── zh-CN.json <- Simplified Chinese
├── zh-TW.json <- Traditional Chinese
├── ja.json <- Japanese
├── ko.json <- Korean
├── de.json <- German
├── es-ES.json <- Spanish
├── fr-FR.json <- French
├── it.json <- Italian
├── pt-BR.json <- Portuguese (Brazil)
└── vi.json <- Vietnamese

Translation file format (flat KV):

{
"dashboard_title": "Plugin Dashboard",
"greeting": "Hello, {{name}}!",
"item_count": "{{count}} items",
"settings_saved": "Settings saved successfully"
}
const title = ctx.i18n.t('dashboard_title');
const greeting = ctx.i18n.t('greeting', { name: 'Alice' });
ctx.i18n.onLanguageChange((lang) => {
console.log('Language changed to:', lang);
// Trigger a UI update
});

OxideTerm attempts to load language files in the following order. Missing files are skipped silently.

Language CodeLanguage
enEnglish
zh-CNSimplified Chinese
zh-TWTraditional Chinese
jaJapanese
koKorean
deGerman
es-ESSpanish
fr-FRFrench
itItalian
pt-BRPortuguese (Brazil)
viVietnamese

Simple localStorage-based KV storage with automatic JSON serialization and deserialization.

ctx.storage.set('myData', { items: [1, 2, 3], updated: Date.now() });
const data = ctx.storage.get('myData');
ctx.storage.remove('myData');

Storage key format: oxide-plugin-{pluginId}-{key}

Limits:

  • constrained by localStorage capacity, usually 5-10 MB per origin
  • failures are handled silently without throwing
  • all values are serialized as JSON, so undefined, function, and Symbol are not supported

Similar to ctx.storage, but with additional features:

  • settings declared in the manifest have default values
  • supports onChange listeners
  • uses the storage key format oxide-plugin-{pluginId}-setting-{settingId}

Each plugin’s storage is fully isolated:

localStorage key format:
oxide-plugin-{pluginId}-{key} <- storage
oxide-plugin-{pluginId}-setting-{settingId} <- settings

Storage is not cleared automatically when a plugin is uninstalled. Data remains so the plugin can be reinstalled later. If you need a full wipe, call the internal clearPluginStorage(pluginId) helper, which is not currently exposed through ctx.


Plugins may call only the Tauri commands declared in contributes.apiCommands.

{
"contributes": {
"apiCommands": ["list_sessions", "get_session_info"]
}
}
try {
const sessions = await ctx.api.invoke('list_sessions');
console.log('Active sessions:', sessions);
} catch (err) {
console.error('Failed to list sessions:', err);
}

OxideTerm’s plugin system includes a built-in circuit breaker that prevents broken plugins from dragging down the entire application.

ParameterValueDescription
MAX_ERRORS10Trigger threshold
ERROR_WINDOW_MS60,000 ms (1 minute)Sliding window
HOOK_BUDGET_MS5 msTerminal hook time budget

Errors counted by the circuit breaker:

  1. exceptions thrown by terminal hooks (inputInterceptor / outputProcessor)
  2. terminal hooks taking longer than 5ms
  3. other runtime errors tracked through trackPluginError()

Trigger flow:

Plugin error
-> trackPluginError(pluginId)
-> accumulate errors within the 60s window
-> reach 10 errors
-> persistAutoDisable(pluginId)
-> plugin-config.json: { enabled: false }
-> store.setPluginState('disabled')
-> unloadPlugin(pluginId)
// Defensive programming inside Terminal hooks
ctx.terminal.registerInputInterceptor((data, { sessionId }) => {
try {
return processInput(data);
} catch (err) {
console.warn('[MyPlugin] Input interceptor error:', err);
return data;
}
});
// Wrap event handlers with try/catch
ctx.events.onConnect((snapshot) => {
try {
handleConnection(snapshot);
} catch (err) {
console.error('[MyPlugin] onConnect error:', err);
}
});
// Wrap API calls with try/catch
try {
const result = await ctx.api.invoke('some_command');
} catch (err) {
ctx.ui.showToast({
title: 'API Error',
description: String(err),
variant: 'error',
});
}

When the circuit breaker trips:

  1. it reads plugin-config.json
  2. sets plugins[pluginId].enabled = false
  3. writes the file back
  4. sets the store state to 'disabled'

That means the plugin remains disabled after restarting OxideTerm. The user must re-enable it manually in Plugin Manager.


All register* and on* methods return a Disposable object:

type Disposable = {
dispose(): void; // becomes a no-op after the first call
};

If you need to dynamically unregister something at runtime, for example toggling a hook based on a setting:

let interceptorDisposable = null;
function enableInterceptor() {
interceptorDisposable = ctx.terminal.registerInputInterceptor(myHandler);
}
function disableInterceptor() {
interceptorDisposable?.dispose();
interceptorDisposable = null;
}
ctx.settings.onChange('enableFilter', (enabled) => {
if (enabled) enableInterceptor();
else disableInterceptor();
});

You do not need to manually clean up anything registered through ctx inside deactivate(). On unload, the system automatically:

  1. walks all tracked Disposables for the plugin
  2. calls dispose() on each one
  3. clears tab views, sidebar panels, input interceptors, output processors, and shortcuts
  4. clears the disposable tracking list itself

deactivate() is meant for cleanup that is outside the Disposable model, such as global references placed on window.


OxideTerm ships with a complete Demo Plugin that serves as a reference implementation.

~/.oxideterm/plugins/oxide-demo-plugin/
├── plugin.json
└── main.js
{
"id": "oxide-demo-plugin",
"name": "OxideTerm Demo Plugin",
"version": "1.0.0",
"description": "A comprehensive demo plugin that exercises all plugin system APIs",
"author": "OxideTerm Team",
"main": "./main.js",
"engines": {
"oxideterm": ">=1.6.0"
},
"contributes": {
"tabs": [
{ "id": "dashboard", "title": "Plugin Dashboard", "icon": "LayoutDashboard" }
],
"sidebarPanels": [
{ "id": "quick-info", "title": "Quick Info", "icon": "Info", "position": "bottom" }
],
"settings": [
{
"id": "greeting", "type": "string", "default": "Hello from Plugin!",
"title": "Greeting Message", "description": "The greeting shown in the dashboard"
},
{
"id": "inputPrefix", "type": "string", "default": "",
"title": "Input Prefix", "description": "If set, prefix all terminal input"
},
{
"id": "logOutput", "type": "boolean", "default": false,
"title": "Log Output", "description": "Log terminal output byte counts to console"
}
],
"terminalHooks": {
"inputInterceptor": true,
"outputProcessor": true,
"shortcuts": [
{ "key": "ctrl+shift+d", "command": "openDashboard" }
]
},
"connectionHooks": ["onConnect", "onDisconnect"]
}
}

The Demo Plugin’s main.js demonstrates how to use the core APIs.

1. Get shared modules, including the UI Kit

const { React, ReactDOM, zustand, lucideReact, ui } = window.__OXIDE__;
const { createElement: h, useState, useEffect, useCallback, useRef } = React;
const { Activity, Wifi, Terminal, Settings } = lucideReact;

2. Create a shared Zustand store

const useDemoStore = zustand.create((set) => ({
eventLog: [],
inputCount: 0,
outputBytes: 0,
connectionCount: 0,
addEvent: (msg) => set((s) => ({
eventLog: [...s.eventLog.slice(-49), { time: new Date().toLocaleTimeString(), msg }],
})),
incInput: () => set((s) => ({ inputCount: s.inputCount + 1 })),
addOutputBytes: (n) => set((s) => ({ outputBytes: s.outputBytes + n })),
setConnectionCount: (n) => set({ connectionCount: n }),
}));

3. Tab component — build the interface with ui.* components and read connections, settings, and storage through a captured ctx reference.

4. Full registration inside activate()

export function activate(ctx) {
window.__DEMO_PLUGIN_CTX__ = ctx;
// UI registration
ctx.ui.registerTabView('dashboard', DashboardTab);
ctx.ui.registerSidebarPanel('quick-info', QuickInfoPanel);
// Terminal hooks
ctx.terminal.registerInputInterceptor((data, { sessionId }) => { /* ... */ });
ctx.terminal.registerOutputProcessor((data, { sessionId }) => { /* ... */ });
ctx.terminal.registerShortcut('openDashboard', () => ctx.ui.openTab('dashboard'));
// Events
ctx.events.onConnect((snapshot) => { /* ... */ });
ctx.events.onDisconnect((data) => { /* ... */ });
ctx.events.on('demo-ping', (data) => { /* ... */ });
// Settings watch
ctx.settings.onChange('greeting', (newVal) => { /* ... */ });
// Storage
const count = (ctx.storage.get('launchCount') || 0) + 1;
ctx.storage.set('launchCount', count);
// Toast
ctx.ui.showToast({ title: 'Demo Plugin Activated', variant: 'success' });
}

5. Cleanup in deactivate()

export function deactivate() {
delete window.__DEMO_PLUGIN_CTX__;
}

  1. Always use shared modules from window.__OXIDE__

    • do not bundle your own copy of React
    • use const { React } = window.__OXIDE__
  2. Respect Manifest declarations

    • every tab, panel, hook, shortcut, and API command must be declared first in plugin.json
    • registering undeclared content throws at runtime
  3. Keep activate() lightweight

    • do not perform heavy computation or long network requests inside activate()
    • it has a 5-second timeout
  4. Keep Terminal Hooks extremely efficient

    • they run on every keystroke or output chunk and must finish within 5ms
    • move heavy work to queueMicrotask() or setTimeout()
    • wrap them defensively in try/catch
  5. Use semantic CSS classes

    • prefer semantic Tailwind classes like text-foreground, bg-card, and border-border
    • do not hard-code color values
  6. Clean up global state

    • delete globals such as window.__MY_GLOBAL__ in deactivate()
    • anything managed through Disposable does not need manual cleanup
  1. Cap event log size to avoid memory leaks:
eventLog: [...s.eventLog.slice(-49), newEntry]
  1. Avoid string decoding inside output processors:
// Bad
const text = new TextDecoder().decode(data);
const processed = text.replace(/pattern/, 'replacement');
return new TextEncoder().encode(processed);
// Good
totalBytes += data.length;
return data;
  1. Delay initialization: use useEffect inside components to load data lazily.
  1. declare only the apiCommands you actually need
  2. do not expose sensitive information on window
  3. do not directly import @tauri-apps/api/core, even though it is technically possible
  4. do not store passwords or private keys in ctx.storage, because localStorage is not encrypted
  1. Snapshots are immutable: all v3 snapshots such as TransferSnapshot and ProfilerMetricsSnapshot are frozen at runtime. Do not mutate them. Create new objects if you need derived data.
  2. Throttled events still require lightweight handlers: transfers.onProgress is throttled to 500ms and profiler.onMetrics to 1s, but handlers should still avoid DOM-heavy or computationally expensive work.
  3. Use namespaces on demand: v3 exposes 19 namespaces, but you only need to use the ones your plugin actually depends on.
  4. Respect Disposable lifecycles: v3 subscriptions such as onTreeChange, onProgress, and onMetrics return Disposables. Clean them up when appropriate, or let the framework manage them when they are registered directly from ctx.
  5. Treat AI data as sensitive: ctx.ai.getMessages() can contain terminal buffer content, so do not log it or send it to external services casually.

Plugin Manager includes a per-plugin log panel. When a plugin has logs, the plugin row shows a 📜 button that opens the log panel.

The log system automatically records:

  • info: successful activation and unload
  • error: load failures, including the concrete reason and suggested fixes, plus circuit breaker trips

Each plugin keeps at most 200 log entries. Use the Clear button in the log panel to remove them.

Common error messages and what they mean:

Error messageMeaningHow to fix
activate() must resolve within 5sActivation timed outMove expensive work into setTimeout or queueMicrotask
ensure your main.js exports an activate() functionMissing activation exportMake sure export function activate(ctx) exists
check that main.js is a valid ES module bundleJS syntax or import errorCheck syntax and ensure the file is valid ESM

All plugin console.log, console.warn, and console.error output appears in DevTools. Internal host logs use prefixes such as [PluginLoader], [PluginEventBridge], and [PluginTerminalHooks].

Useful debugging commands:

// In DevTools Console
// Show all loaded plugins
JSON.stringify([...window.__ZUSTAND_PLUGIN_STORE__?.getState?.()?.plugins?.entries?.()] ?? 'store not found');
// Show plugin store state if you exposed it globally
useDemoStore.getState()
// Trigger a toast manually
window.__DEMO_PLUGIN_CTX__?.ui.showToast({ title: 'Test', variant: 'success' });
// Inspect current connections
window.__DEMO_PLUGIN_CTX__?.connections.getAll();
  • Status Badge shows active, error, or disabled
  • Error Message shows detailed load/runtime failure information
  • Reload hot-reloads the plugin by unloading and loading it again
  • Refresh rescans disk to discover new plugins or remove missing ones
SymptomPossible cause
Load failure: module must export "activate"The entry file does not export activate()
Load failure: timed out after 5000msactivate() contains a Promise that never resolves
Tab does not appearYou forgot to call ctx.ui.registerTabView() in activate()
Hooks do not workterminalHooks.inputInterceptor: true was not declared in the Manifest
Toast does not appearCheck the variant spelling: default, success, error, or warning
Shortcut does not workMake sure the terminal pane has focus
Reading a setting returns undefinedMake sure the setting key matches settings[].id in the Manifest
The plugin was auto-disabledThe circuit breaker was triggered; inspect the Plugin Manager log viewer or DevTools for errors and timeout warnings
Styles look wrong or fight the themeYou used hard-coded colors instead of semantic classes

Yes. OxideTerm provides a standalone type definition file, plugin-api.d.ts, so you can get full IntelliSense support without cloning the full OxideTerm source tree.

Step 1: get the type definitions

Copy plugin-development/plugin-api.d.ts from the OxideTerm repository into your plugin project.

Step 2: configure tsconfig.json

{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": ".",
"strict": true
},
"include": ["plugin-api.d.ts", "src/**/*.ts"]
}

Step 3: write a typed plugin

import type { PluginContext } from '../plugin-api';
export function activate(ctx: PluginContext) {
ctx.ui.showToast({ title: 'Hello!', variant: 'success' });
ctx.events.onConnect((snapshot) => {
console.log(`Connected to ${snapshot.host}`);
});
}

Step 4: compile to ESM

Terminal window
# esbuild (recommended)
npx esbuild src/main.ts --bundle --format=esm --outfile=main.js --external:react
# or tsc
npx tsc

Do not bundle React. Get it from window.__OXIDE__ at runtime.

  • v1 single-file plugins loaded through Blob URLs do not support internal relative imports. Use a bundler such as esbuild or rollup to collapse the plugin into one file.
  • v2 package plugins loaded through the local HTTP server support multi-file layouts and standard relative import statements.

For v1 plugins, recommended options are:

  1. bundle everything into one file with esbuild or rollup
  2. keep all code in a single main.js
Terminal window
npx esbuild src/index.ts \
--bundle \
--format=esm \
--outfile=main.js \
--external:react \
--external:react-dom

Not directly. Plugins can only:

  • call declared backend commands through ctx.api.invoke()
  • use ctx.storage on top of localStorage

Yes, but there are two distinct cases:

  1. for ordinary JSON APIs, use the browser’s native fetch() directly
  2. for WebDAV, S3-compatible object storage, Dropbox, or other binary-heavy requests that often run into WebView CORS restrictions, declare plugin_http_request and route the request through the host Rust backend via ctx.api.invoke()

plugin_http_request allows only HTTP/HTTPS URLs. Its request body is passed as bodyBase64, and the response returns { status, headers, bodyBase64 }. This is usually more reliable than direct plugin-side fetch(), especially for sync plugins.

By default, plugins are plain JS and should use React.createElement. If you want JSX:

  1. use esbuild with --jsx=automatic --jsx-import-source=react
  2. or use Babel with @babel/plugin-transform-react-jsx
  3. mark React as external and get it from window.__OXIDE__ at runtime

Q: Can plugins communicate with each other?

Section titled “Q: Can plugins communicate with each other?”

In the current design, ctx.events.on() and ctx.events.emit() are namespace-isolated. Options for cross-plugin communication include:

  1. shared globals such as window.__SHARED_DATA__
  2. the lower-level event bridge, if you understand the internal API well enough
  3. a future dedicated cross-plugin communication channel, which is still only planned

Q: What should I do if my plugin was auto-disabled?

Section titled “Q: What should I do if my plugin was auto-disabled?”
  1. click the plugin’s 📜 icon in Plugin Manager to inspect the logs and identify the concrete error and suggested fix
  2. also inspect DevTools for errors and timeout warnings
  3. fix the underlying performance or correctness issue
  4. re-enable the plugin in Plugin Manager
  5. or edit ~/.oxideterm/plugin-config.json manually:
{
"plugins": {
"your-plugin-id": {
"enabled": true
}
}
}

Q: Can plugins modify OxideTerm’s interface?

Section titled “Q: Can plugins modify OxideTerm’s interface?”

Through the declarative API, plugins can:

  • add Tab views
  • add Sidebar panels
  • show toast notifications and confirmations
  • register context menu items through ctx.ui.registerContextMenu
  • register status bar items through ctx.ui.registerStatusBarItem
  • register keybindings through ctx.ui.registerKeybinding
  • show notifications through ctx.ui.showNotification
  • show progress indicators through ctx.ui.showProgress

They cannot:

  • modify existing host UI components
  • modify menus or toolbars directly

Note: plugins may inject custom CSS through ctx.assets.loadCSS() or the Manifest styles field.

Q: Where are plugin configuration files stored?

Section titled “Q: Where are plugin configuration files stored?”
File / locationDescription
~/.oxideterm/plugins/{id}/plugin.jsonPlugin Manifest
~/.oxideterm/plugins/{id}/main.jsPlugin code
~/.oxideterm/plugin-config.jsonGlobal plugin enable/disable state
localStorage: oxide-plugin-{id}-*Plugin storage data
localStorage: oxide-plugin-{id}-setting-*Plugin setting values

Recommended: use plugin-development/plugin-api.d.ts directly. It is a standalone, zero-dependency type definition file that you can copy into your plugin project for IntelliSense. See FAQ: Can plugins use TypeScript?

Below is a practical TypeScript reference excerpt aligned with the current documentation:

oxideterm-plugin.d.ts
export type Disposable = {
dispose(): void;
};
export type SshConnectionState =
| 'idle'
| 'connecting'
| 'active'
| 'disconnecting'
| 'disconnected'
| 'reconnecting'
| 'link_down'
| { error: string };
export type ConnectionSnapshot = Readonly<{
id: string;
host: string;
port: number;
username: string;
state: SshConnectionState;
refCount: number;
keepAlive: boolean;
createdAt: string;
lastActive: string;
terminalIds: readonly string[];
parentConnectionId?: string;
}>;
export type PluginTabProps = {
tabId: string;
pluginId: string;
};
export type PluginEventsAPI = {
onConnect(handler: (snapshot: ConnectionSnapshot) => void): Disposable;
onDisconnect(handler: (snapshot: ConnectionSnapshot) => void): Disposable;
onLinkDown(handler: (snapshot: ConnectionSnapshot) => void): Disposable;
onReconnect(handler: (snapshot: ConnectionSnapshot) => void): Disposable;
on(name: string, handler: (data: unknown) => void): Disposable;
emit(name: string, data: unknown): void;
};
export type ContextMenuTarget = 'terminal' | 'sftp' | 'tab' | 'sidebar';
export type ContextMenuItem = {
label: string;
icon?: string;
handler: () => void;
when?: () => boolean;
};
export type StatusBarItemOptions = {
text: string;
icon?: string;
tooltip?: string;
alignment: 'left' | 'right';
priority?: number;
onClick?: () => void;
};
export type StatusBarHandle = {
update(options: Partial<StatusBarItemOptions>): void;
dispose(): void;
};
export type ProgressReporter = {
report(value: number, total: number, message?: string): void;
};
export type PluginUIAPI = {
registerTabView(tabId: string, component: React.ComponentType<PluginTabProps>): Disposable;
registerSidebarPanel(panelId: string, component: React.ComponentType): Disposable;
registerCommand(id: string, opts: { label: string; icon?: string; shortcut?: string; section?: string }, handler: () => void): Disposable;
openTab(tabId: string): void;
showToast(opts: { title: string; description?: string; variant?: 'default' | 'success' | 'error' | 'warning' }): void;
showConfirm(opts: { title: string; description: string }): Promise<boolean>;
registerContextMenu(target: ContextMenuTarget, items: ContextMenuItem[]): Disposable;
registerStatusBarItem(options: StatusBarItemOptions): StatusBarHandle;
registerKeybinding(keybinding: string, handler: () => void): Disposable;
showNotification(opts: { title: string; body?: string; severity?: 'info' | 'warning' | 'error' }): void;
showProgress(title: string): ProgressReporter;
getLayout(): Readonly<{ sidebarCollapsed: boolean; activeTabId: string | null; tabCount: number }>;
onLayoutChange(handler: (layout: Readonly<{ sidebarCollapsed: boolean; activeTabId: string | null; tabCount: number }>) => void): Disposable;
};
export type PluginActiveTerminalTarget = Readonly<{
sessionId: string;
terminalType: 'terminal' | 'local_terminal';
nodeId: string | null;
connectionId: string | null;
connectionState: string | null;
label: string | null;
}>;
export type TerminalHookContext = {
sessionId: string;
nodeId: string;
};
export type InputInterceptor = (data: string, context: TerminalHookContext) => string | null;
export type OutputProcessor = (data: Uint8Array, context: TerminalHookContext) => Uint8Array;
export type PluginTerminalAPI = {
registerInputInterceptor(handler: InputInterceptor): Disposable;
registerOutputProcessor(handler: OutputProcessor): Disposable;
registerShortcut(command: string, handler: () => void): Disposable;
getActiveTarget(): PluginActiveTerminalTarget | null;
writeToActive(text: string): boolean;
writeToNode(nodeId: string, text: string): void;
getNodeBuffer(nodeId: string): string | null;
getNodeSelection(nodeId: string): string | null;
search(nodeId: string, query: string, options?: { caseSensitive?: boolean; regex?: boolean; wholeWord?: boolean }): Promise<Readonly<{ matches: ReadonlyArray<unknown>; total_matches: number }>>;
getScrollBuffer(nodeId: string, startLine: number, count: number): Promise<ReadonlyArray<Readonly<{ text: string; lineNumber: number }>>>;
getBufferSize(nodeId: string): Promise<Readonly<{ currentLines: number; totalLines: number; maxLines: number }>>;
clearBuffer(nodeId: string): Promise<void>;
};
export type PluginSettingsAPI = {
get<T>(key: string): T;
set<T>(key: string, value: T): void;
onChange(key: string, handler: (newValue: unknown) => void): Disposable;
};
export type PluginI18nAPI = {
t(key: string, params?: Record<string, string | number>): string;
getLanguage(): string;
onLanguageChange(handler: (lang: string) => void): Disposable;
};
export type PluginStorageAPI = {
get<T>(key: string): T | null;
set<T>(key: string, value: T): void;
remove(key: string): void;
};
export type PluginBackendAPI = {
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
};
export type PluginAssetsAPI = {
loadCSS(relativePath: string): Promise<Disposable>;
getAssetUrl(relativePath: string): Promise<string>;
revokeAssetUrl(url: string): void;
};
export type PluginContext = Readonly<{
pluginId: string;
connections: PluginConnectionsAPI;
events: PluginEventsAPI;
ui: PluginUIAPI;
terminal: PluginTerminalAPI;
settings: PluginSettingsAPI;
i18n: PluginI18nAPI;
storage: PluginStorageAPI;
api: PluginBackendAPI;
assets: PluginAssetsAPI;
sftp: PluginSftpAPI;
forward: PluginForwardAPI;
sessions: PluginSessionsAPI;
transfers: PluginTransfersAPI;
profiler: PluginProfilerAPI;
eventLog: PluginEventLogAPI;
ide: PluginIdeAPI;
ai: PluginAiAPI;
app: PluginAppAPI;
}>;

{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "name", "version", "main"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]*$",
"description": "Unique plugin identifier"
},
"name": { "type": "string", "description": "Human-readable plugin name" },
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+", "description": "Semver version" },
"description": { "type": "string" },
"author": { "type": "string" },
"main": { "type": "string", "description": "Relative path to ESM entry file" },
"manifestVersion": {
"type": "integer", "enum": [1, 2], "default": 1,
"description": "Manifest schema version; set to 2 for v2 Package format"
},
"format": {
"type": "string", "enum": ["bundled", "package"], "default": "bundled",
"description": "bundled = single-file Blob URL; package = multi-file HTTP Server"
},
"assets": {
"type": "string",
"description": "Relative path to the assets directory (v2 Package only)"
},
"styles": {
"type": "array", "items": { "type": "string" },
"description": "CSS files to auto-load on activation (v2 Package only)"
},
"sharedDependencies": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Dependencies provided by the host through window.__OXIDE__"
},
"repository": {
"type": "string",
"description": "Repository URL for source code"
},
"checksum": {
"type": "string",
"description": "SHA-256 hash of the main entry file for integrity verification"
},
"engines": {
"type": "object",
"properties": {
"oxideterm": { "type": "string", "pattern": "^>=?\\d+\\.\\d+\\.\\d+" }
}
},
"locales": { "type": "string", "description": "Relative path to the locales directory" },
"contributes": {
"type": "object",
"properties": {
"tabs": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "title", "icon"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"icon": { "type": "string", "description": "Lucide React icon name" }
}
}
},
"sidebarPanels": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "title", "icon"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"icon": { "type": "string" },
"position": { "type": "string", "enum": ["top", "bottom"], "default": "bottom" }
}
}
},
"settings": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "type", "default", "title"],
"properties": {
"id": { "type": "string" },
"type": { "type": "string", "enum": ["string", "number", "boolean", "select"] },
"default": {},
"title": { "type": "string" },
"description": { "type": "string" },
"options": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "value"],
"properties": {
"label": { "type": "string" },
"value": {}
}
}
}
}
}
},
"terminalHooks": {
"type": "object",
"properties": {
"inputInterceptor": { "type": "boolean" },
"outputProcessor": { "type": "boolean" },
"shortcuts": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "command"],
"properties": {
"key": { "type": "string" },
"command": { "type": "string" }
}
}
}
}
},
"terminalTransports": {
"type": "array",
"items": { "type": "string", "enum": ["telnet"] }
},
"connectionHooks": {
"type": "array",
"items": { "type": "string", "enum": ["onConnect", "onDisconnect", "onReconnect", "onLinkDown"] }
},
"apiCommands": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
}

Appendix B: Internal Architecture File Quick Reference

Section titled “Appendix B: Internal Architecture File Quick Reference”
FileResponsibility
src/types/plugin.tsAll plugin type definitions
src/store/pluginStore.tsZustand plugin state management
src/lib/plugin/pluginLoader.tsLifecycle management: discovery, loading, unloading, circuit breaker
src/lib/plugin/pluginContextFactory.tsBuilds the frozen PluginContext membrane
src/lib/plugin/pluginEventBridge.tsEvent bridge from appStore to plugin events
src/lib/plugin/pluginTerminalHooks.tsTerminal I/O hook pipeline
src/lib/plugin/pluginStorage.tslocalStorage KV wrapper
src/lib/plugin/pluginSettingsManager.tsSetting declarations, persistence, and change notifications
src/lib/plugin/pluginI18nManager.tsPlugin i18n wrapper around i18next
src/lib/plugin/pluginUtils.tsShared utilities such as path validation and safety checks
src/lib/plugin/pluginUIKit.tsxBuilt-in UI Kit component library
src-tauri/src/commands/plugin.rsRust backend for file I/O and path safety
src-tauri/src/commands/plugin_server.rsPlugin file server for multi-file HTTP loading

| src/components/plugin/PluginManagerView.tsx | Plugin Manager UI | | src/components/plugin/PluginTabRenderer.tsx | Plugin Tab renderer | | src/components/plugin/PluginSidebarRenderer.tsx | Plugin Sidebar renderer | | src/components/plugin/PluginConfirmDialog.tsx | Themed confirmation dialog | | src/lib/plugin/pluginSnapshots.ts | v3 snapshot generation factory with freeze + deep copy | | src/lib/plugin/pluginThrottledEvents.ts | v3 throttled event bridges for transfers and profiler |