YAET uses a plugin system to manage connection types (SSH, Telnet, FTP, etc.). Each plugin provides:
- Backend: IPC handler + runtime connector (Node.js, runs in Electron main process)
- Frontend: profile form component (Web Component or Angular component)
- Manifest: Metadata declaration (IPC channels, profile type, category)
Plugins live in two locations:
- Bundled:
plugins/<id>/at the project root (shipped with the app) - External:
~/.yaet/plugins/<id>/(user-installed)- Security: external plugins are disabled by default and must be explicitly enabled via
pluginManager.enablePlugin(id)(stored in~/.yaet/plugins/enabled.json) - Security: external plugins that conflict with bundled plugin IDs are skipped entirely
- Security: external plugins run with
npm install --ignore-scripts(blocks postinstall scripts) - Security: IPC is restricted — plugins can only register channels declared in their manifest
- Security:
secretServiceis filtered bysecretTypes— plugins only access declared secret types - Security:
projectRequireis whitelisted — only modules declared in manifestdependencies+ Node.js builtins - Security: manifest SHA-256 integrity check; optional HMAC-SHA256 signature
- Security: CSP removes
unsafe-inline/unsafe-eval; frontend loaded via blob URL
- Security: external plugins are disabled by default and must be explicitly enabled via
See ext-plugins-example/webdav-file-explorer/ for a working external WebDAV FILE_EXPLORER plugin example.
Electron app starts
│
├─ PluginManager.discover() ← scans plugins/ AND ~/.yaet/plugins/
│ ├─ for each plugin dir with package.json:
│ │ └─ auto-run npm install --ignore-scripts if node_modules missing or outdated
│ └─ external plugins that conflict with bundled ids are SKIPPED (security)
├─ PluginManager.writeMergedManifest() ← writes generated-plugin-manifest.json to both locations
│
├─ new BrowserWindow(preload.js)
│ └─ preload.js (sandboxed, no fs) fetches the merged manifest from the
│ main process via sync IPC `plugins.getMergedManifestSync`
│ └─ merges plugin IPC channels into whitelist (fail-closed: core only)
│
├─ initHandlerBeforeSettingLoad() ← core IPC handlers
└─ PluginManager.loadAll(context) ← plugin backends register their IPC handlers
└─ context.projectRequire available for external plugin npm deps
User creates SSH connection
→ SessionService.create(profile, 'SSH_TERMINAL')
→ registry.getExternalPlugin('SSH_TERMINAL') ← found? use PluginSession
→ new PluginSession(profile, ..., channels, profileData, secretStorage)
→ PluginSession.open()
→ resolveSecretToConfig() ← resolve secrets before IPC
→ ipcRenderer.send('session.open.terminal.ssh', data)
→ preload.js checks whitelist (includes plugin channels) ✓
→ plugin backend handler receives event ✓
→ SshTerminalSession connects via ssh2 ✓
→ output streams back via 'terminal.output' ✓
Backend code runs in Electron's main process (Node.js). It is loaded at runtime via require().
electronMain.js
│
├─ app.on('ready')
│ │
│ ├─ new PluginManager(__dirname, log)
│ ├─ pluginManager.discover()
│ │ └─ for each directory in plugins/:
│ │ ├─ read manifest.json
│ │ ├─ validate fields (id, category, profileType)
│ │ └─ store in plugins Map
│ │
│ ├─ pluginManager.writeMergedManifest()
│ │ └─ combine all plugin ipc channels → plugins/generated-plugin-manifest.json
│ │ (served to the sandboxed preload via sync IPC; preload never reads files)
│ │
│ ├─ new BrowserWindow({ preload: 'preload.js' })
│ │
│ ├─ initHandlerBeforeSettingLoad() ← core handlers (non-plugin)
│ │
│ └─ pluginManager.loadAll(context)
│ └─ for each enabled plugin:
│ ├─ require('plugins/<id>/backend/index.js') ← Node.js dynamic require
│ ├─ backendModule.register(context) ← plugin registers its IPC handlers
│ └─ done
Key points about backend loading:
require()is a synchronous Node.js call — plugins load one by one, in directory order- The
contextobject provides access to core services (ipcMain,logger,sessionRegistry, etc.) - Some context values are lazy getters (functions) because they aren't initialized yet at load time
- A plugin's
register()function typically callsipcMain.on(...)oripcMain.handle(...)to listen for IPC events - If a plugin's
backend/index.jshas syntax errors or missing dependencies,require()throws and that plugin is skipped (other plugins continue loading) - External plugins can use
context.projectRequire(moduleName)to resolve npm dependencies from the project'snode_modules
PluginManager.loadAll(context)
│
├─ plugins/ssh-terminal (bundled)
│ ├─ require('./plugins/ssh-terminal/backend/index.js')
│ └─ register({ ipcMain, logger, ... })
│ └─ ipcMain.on('session.open.terminal.ssh', handler)
│
├─ ~/.yaet/plugins/my-terminal (external, non-conflicting id)
│ ├─ require('~/.yaet/plugins/my-terminal/backend/index.js')
│ │ └─ const { MyTerminalSession } = require('./my-terminal.connector')
│ │ └─ uses context.projectRequire('ssh2') for npm deps
│ └─ register({ ipcMain, logger, projectRequire, ... })
│ └─ ipcMain.on('session.open.terminal.my-terminal', handler)
│ (an external id conflicting with a bundled plugin is SKIPPED, never overrides)
│
├─ plugins/telnet-terminal (bundled)
│ ├─ require('./plugins/telnet-terminal/backend/index.js')
│ │ └─ const { TelnetSession } = require('./telnet.connector') ← telnet-client loaded here
│ └─ register({ ipcMain, logger, ... })
│ └─ ipcMain.on('session.open.terminal.telnet', handler)
│
└─ ...
Bundled plugins have their .ts files compiled into the Angular bundle via tsconfig.app.json.
External plugins ship a pre-built JS file (frontend/index.js) that is loaded at runtime:
App starts → app.component.ts → ngOnInit()
│
├─ PluginLoaderService.loadExternalPlugins()
│ ├─ Read ~/.yaet/plugins/generated-plugin-manifest.json (via IPC)
│ ├─ For each plugin where source === 'external':
│ │ ├─ Read frontend code via IPC: plugins.readFrontend(id)
│ │ ├─ Execute via blob-URL <script> (CSP-safe, no unsafe-inline needed)
│ │ ├─ Plugin registers: window.__<ID>_PLUGIN__ = { manifest, profileFormElement }
│ │ └─ Register with PluginRegistryService.registerExternalPlugin()
│ │
│ └─ External plugins reuse RemoteTerminalProfileFormComponent for profile forms
│
├─ pluginRegistry.getExternalPlugin('SSH_TERMINAL')
│ └─ returns { id, name, category, profileType, profileFormElement, ipcChannels }
│
└─ Profile form: @default case in profile-form.component.html
└─ <app-remote-terminal-profile-form> (shared component for all terminal types)
Why the difference?
| Aspect | Backend | Frontend (Bundled) | Frontend (External) |
|---|---|---|---|
| Runtime | Node.js (main process) | Browser (renderer process) | Browser (renderer process) |
| Loading | require() at app startup |
Compiled into bundle at build time | Blob-URL <script> at runtime (CSP-safe) |
| Discovery | PluginManager scans plugins/ |
tsconfig.app.json includes plugins/**/*.ts |
Reads merged manifest |
| Registration | ipcMain.on/handle() in register() |
PluginRegistryService.register() |
window.__<ID>_PLUGIN__ + registerExternalPlugin() |
| npm deps | context.projectRequire() for external |
N/A (bundled) | N/A (backend handles deps) |
| Hot reload | Possible (require cache) | Requires rebuild (ng just reload page) |
Reload page |
plugins/
└── my-connection-type/
├── manifest.json # Plugin metadata
├── backend/
│ ├── index.js # register(context) — entry point
│ └── my.connector.js # Runtime connector (optional, can be inline)
└── frontend/
├── my-electron.service.ts # Frontend IPC service
└── my.plugin.ts # Frontend registration
~/.yaet/plugins/my-connection-type/
├── package.json # Optional: npm dependencies (auto-installed on startup)
├── manifest.json # Plugin metadata
├── backend/
│ ├── index.js # register(context) — entry point
│ └── my.connector.js # Runtime connector (self-contained, uses context.projectRequire for npm deps)
└── frontend/
└── index.js # Pre-built JS bundle (Web Component)
See ext-plugins-example/webdav-file-explorer/ for a complete working example.
Important: the directory name MUST match manifest.json → id. PluginManager constructs backend path as path.join(baseDir, id, backend).
External plugins can declare npm dependencies in their own package.json. On startup, PluginManager detects the package.json and runs npm install --ignore-scripts in the plugin directory if node_modules is missing or out of date (scripts are never executed — postinstall hooks are blocked).
When to use:
- Your plugin needs a build-time dependency (e.g.
spice-clientfor frontend bundling) - You prefer self-contained dependency management over
context.projectRequire() - Your plugin has npm packages not available in the core application
Example (~/.yaet/plugins/my-plugin/package.json):
{
"name": "my-plugin",
"version": "1.0.0",
"private": true,
"dependencies": {
"some-runtime-pkg": "^1.0.0"
},
"devDependencies": {
"some-build-tool": "^2.0.0"
}
}Both runtime (dependencies) and build-time (devDependencies) packages are installed. The frontend bundle should be pre-built before distribution — only runtime dependencies are needed for the backend at load time.
Mix and match: You can combine self-managed deps with context.projectRequire(). The plugin's own node_modules is checked first by Node.js require(), falling back to projectRequire for core packages.
{
"id": "my-connection",
"name": "My Connection",
"version": "1.0.0",
"description": "Description of what this plugin does",
"author": "Your Name",
"category": "TERMINAL",
"profileType": "MY_CONNECTION",
"defaultPort": 22,
"icon": "terminal",
"enabled": true,
"dependencies": [],
"ipc": {
"send": ["session.open.my-connection", "session.close.my-connection"],
"invoke": [],
"on": ["session.disconnect.my-connection"]
},
"backend": "./backend/index.js",
"frontend": {
"entry": "./frontend/index.js",
"profileFormElement": "my-connection-profile-form"
},
"secretTypes": ["LOGIN_PASSWORD"],
"supportedAuthTypes": ["N/A", "login", "secret"]
}Manifest fields:
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier (kebab-case) |
name |
Yes | Display name |
version |
Yes | Semver version |
category |
Yes | TERMINAL, FILE_EXPLORER, REMOTE_DESKTOP, or CUSTOM |
profileType |
Yes | Must match a value in ProfileType enum |
icon |
Yes | Material icon name |
enabled |
Yes | Whether the plugin loads on startup |
ipc.send |
No | IPC channels the plugin listens on (renderer → main) |
ipc.invoke |
No | IPC channels the plugin handles (renderer → main, request/response) |
ipc.on |
No | IPC channels the plugin sends (main → renderer) |
secretTypes |
No | Which secret types this plugin supports |
supportedAuthTypes |
No | N/A, login, secret |
Create backend/index.js:
const { MyConnector } = require('./my.connector');
function register(context) {
const { ipcMain, logger, sessionRegistry, terminalMap } = context;
ipcMain.on('session.open.my-connection', async (event, data) => {
const session = new MyConnector(logger, context.projectRequire);
// Wire up events
session.on('output', ({ data: output }) => {
event.sender.send('terminal.output', { id: data.terminalId, data: output });
});
session.on('error', ({ error }) => {
event.sender.send('error', { category: 'my-connection', id: data.terminalId, error });
});
session.on('disconnect', ({ error }) => {
event.sender.send('session.disconnect.my-connection', { id: data.terminalId, error: !!error });
});
try {
// Resolve proxy if needed
let proxy = null;
if (data.proxyId && context.proxyService) {
const proxies = typeof context.proxyService === 'function'
? context.proxyService() : context.proxyService;
if (proxies?.proxies) {
proxy = proxies.proxies.find(p => p.id === data.proxyId);
}
}
// Resolve secrets if needed
const secretRepo = typeof context.secretService === 'function'
? context.secretService() : context.secretService;
await session.connect({
...data.config,
proxy,
secretRepo,
id: data.terminalId,
});
// Register in session registry (for AI tools)
const registry = typeof sessionRegistry === 'function'
? sessionRegistry() : sessionRegistry;
if (registry) registry.register(data.terminalId, 'my-connection', 'user', session);
// Register in terminalMap (for shared terminalHandler.js resize/input)
if (terminalMap) {
terminalMap.set(data.terminalId, {
type: 'my-connection',
process: session.conn,
stream: session.stream,
callback: (input) => session.write(input),
});
}
} catch (error) {
event.sender.send('error', {
category: 'my-connection',
id: data.terminalId,
error: error.message,
});
}
});
ipcMain.on('session.close.my-connection', (event, data) => {
const registry = typeof sessionRegistry === 'function'
? sessionRegistry() : sessionRegistry;
const entry = registry?.get(data.terminalId);
if (entry?.session) entry.session.close();
registry?.unregister(data.terminalId);
});
logger.info('[my-connection] Plugin registered');
}
module.exports = { register };Context object available to plugins:
| Property | Type | Description |
|---|---|---|
ipcMain |
Restricted wrapper (handle/on/removeHandler/removeAllListeners) |
Register IPC handlers — only for channels declared in the manifest's ipc section |
logger |
electron-log |
Logging |
terminalMap |
Map |
Shared terminal session map (for resize/input routing) |
sessionRegistry |
SessionRegistry or () => SessionRegistry |
Session tracking for AI tools |
runtimeAPI |
Restricted wrapper | registerConnector() only for the manifest's declared profileType |
proxyService |
object or () => object |
Proxy configuration |
secretService |
Restricted wrapper | Secrets filtered by the manifest's declared secretTypes |
expressApp |
Express app or () => Express |
Backend REST API (for file explorers) |
projectRequire |
Function | createRequire() from project root — use for npm deps like require('ssh2') |
appRoot |
string | Absolute path to the project root (for reference) |
Create backend/my.connector.js:
const { EventEmitter } = require('events');
class MyConnector extends EventEmitter {
constructor(log, projectRequire) {
super();
this.log = log;
this._projectRequire = projectRequire;
this.conn = null;
this.stream = null;
}
async connect(options) {
// Use projectRequire to resolve npm deps (works for both bundled and external plugins)
const { SomeClient } = this._projectRequire('some-npm-package');
const { host, port, /* ... */ } = options;
// Establish connection using your protocol library
this.conn = await createConnection({ host, port });
this.stream = this.conn;
// Forward output events
this.conn.on('data', (data) => {
this.emit('output', { data: data.toString() });
});
this.conn.on('close', () => {
this.emit('disconnect', { error: false });
});
}
async write(data) {
if (this.stream) {
this.stream.write(data);
return true;
}
return false;
}
async resize(cols, rows) {
// Protocol-specific resize
}
async close() {
if (this.conn) {
this.conn.end();
this.conn = null;
this.stream = null;
}
}
}
module.exports = { MyConnector };Important: Pass projectRequire to the connector constructor so it can resolve npm deps. This is required for external plugins — without it, require('some-npm-package') will fail because the plugin lives in ~/.yaet/plugins/ where node_modules doesn't exist.
For external plugins, create frontend/index.js — a pre-built JS bundle that registers a Web Component:
class MyConnectionProfileForm extends HTMLElement {
constructor() {
super();
this._shadow = this.attachShadow({mode: 'open'});
}
connectedCallback() {
this._shadow.innerHTML = `
<style>
:host { display: block; padding: 16px; }
.form-field { margin-bottom: 12px; }
label { display: block; font-size: 12px; color: #999; margin-bottom: 4px; }
input { width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px;
background: #1e1e1e; color: #fff; box-sizing: border-box; }
</style>
<div class="form-field">
<label>Host</label>
<input type="text" id="host" placeholder="hostname or IP" />
</div>
<div class="form-field">
<label>Port</label>
<input type="number" id="port" value="22" />
</div>
`;
}
setForm(form) { /* pass form data to the element */ }
setProfile(profile) { /* pass profile data to the element */ }
}
// Register the custom element
customElements.define('my-connection-profile-form', MyConnectionProfileForm);
// Expose metadata for the host app
// Key MUST be __<ID>_PLUGIN__ where ID = manifest.id with hyphens → underscores, uppercased
window.__MY_CONNECTION_PLUGIN__ = {
manifest: { id: 'my-connection', category: 'TERMINAL', profileType: 'MY_CONNECTION' },
profileFormElement: 'my-connection-profile-form',
};Auth integration: The core provides a reusable <app-auth-form> component for auth type selection (None / Login / Secret). External plugins declare supportedAuthTypes and secretTypes in their manifest — the app automatically renders the auth component below the Web Component form. The Web Component only needs to handle plugin-specific fields (like URL for WebDAV).
Alternatively, for terminal-type plugins, you can skip the custom element and reuse the shared RemoteTerminalProfileFormComponent — no frontend code needed at all. The @default case in profile-form.component.html handles this automatically for external terminal plugins.
For external plugins, registration happens automatically:
PluginManagerdiscovers the plugin in~/.yaet/plugins/<id>/PluginLoaderServiceloadsfrontend/index.jsvia IPC- The plugin's
window.__<ID>_PLUGIN__metadata is read PluginRegistryService.registerExternalPlugin()stores the plugin info
For bundled plugins, create frontend/my.plugin.ts:
import { inject } from '@angular/core';
import { PluginRegistryService } from '../../../src/app/plugin/services/plugin-registry.service';
import { ProfileCategory, ProfileType } from '../../../src/app/domain/profile/Profile';
const MY_MANIFEST = {
id: 'my-connection',
name: 'My Connection',
version: '1.0.0',
category: ProfileCategory.TERMINAL,
profileType: ProfileType.MY_CONNECTION,
defaultPort: 22,
icon: 'terminal',
enabled: true,
secretTypes: ['LOGIN_PASSWORD'],
supportedAuthTypes: ['N/A', 'login', 'secret'],
};
export function registerMyPlugin() {
const registry = inject(PluginRegistryService);
import('./my-electron.service').then(({ MyElectronService }) => {
import('../../../src/app/components/terminal/terminal.component').then(({ TerminalComponent }) => {
registry.register({
manifest: MY_MANIFEST,
profileFormComponent: TerminalComponent, // or a custom form
sessionComponent: TerminalComponent, // shared terminal view
});
});
});
}For external plugins, no core changes are needed — the plugin is discovered automatically.
For bundled plugins, you may need:
6a. Add ProfileType (if new type):
In src/app/domain/profile/Profile.ts:
export enum ProfileType {
// ... existing types
MY_CONNECTION = 'MY_CONNECTION',
}6b. Add Session class (if needed):
In src/app/domain/session/MySession.ts:
import { Profile, ProfileType } from '../profile/Profile';
import { TabService } from '../../services/tab.service';
import { Session } from './Session';
export class MySession extends Session {
private myService: any;
constructor(profile: Profile, profileType: ProfileType,
tabService: TabService, myService: any) {
super(profile, profileType, tabService);
this.myService = myService;
}
override open(): void {
this.myService.openSession(this);
super.open();
}
override close(): void {
this.myService.closeSession(this);
super.close();
}
}6c. Update SessionService:
In src/app/services/session.service.ts:
import { MyElectronService } from '../../../plugins/my-connection/frontend/my-electron.service';
import { MySession } from '../domain/session/MySession';
// In constructor:
private myService: MyElectronService,
// In create() switch:
case ProfileType.MY_CONNECTION:
return new MySession(profile, profileType, this.tabService, this.myService);External plugins are distributed as directories that users copy to ~/.yaet/plugins/<id>/. There is no special archive format — just a folder with the correct structure.
For plugins like webdav-file-explorer that have no npm dependencies and no frontend build step:
my-plugin/
├── manifest.json
├── backend/
│ ├── index.js
│ └── my.connector.js
└── frontend/
└── index.js # Hand-written Web Component (no build needed)
Distribution: Zip the directory and share. Users extract to ~/.yaet/plugins/my-plugin/.
For plugins that need npm packages for the backend (e.g. ssh2, telnet-client):
my-plugin/
├── package.json # Declares npm dependencies
├── manifest.json
├── backend/
│ ├── index.js
│ └── my.connector.js
└── frontend/
└── index.js
How it works: On startup, PluginManager detects package.json and runs npm install --ignore-scripts automatically if node_modules is missing or outdated. No manual install step needed by the user.
Distribution: Zip the directory without node_modules/. Users extract to ~/.yaet/plugins/my-plugin/, and the app handles npm install --ignore-scripts on first launch.
For plugins that have frontend dependencies (e.g. a JS library like spice-client), you need a build script to bundle everything into a single frontend/index.js:
my-plugin/
├── package.json # devDependencies for build, optional dependencies
├── build.js # Build script (concatenates sources into frontend/index.js)
├── manifest.json
├── src/
│ ├── my-profile-form.js
│ └── my-session.js
├── backend/
│ ├── index.js
│ └── my.connector.js
└── frontend/
└── index.js # Generated by build.js (do NOT hand-edit)
Build script example (from spice-remote-desktop/build.js):
const fs = require('fs');
const path = require('path');
const SRC_DIR = path.resolve(__dirname, 'src');
const OUT_DIR = path.resolve(__dirname, 'frontend');
const files = [
path.join(SRC_DIR, 'my-profile-form.js'),
path.join(SRC_DIR, 'my-session.js'),
];
if (!fs.existsSync(OUT_DIR)) {
fs.mkdirSync(OUT_DIR, { recursive: true });
}
let bundle = '(function(){\n"use strict";\n\n';
for (const file of files) {
bundle += fs.readFileSync(file, 'utf8');
bundle += '\n\n';
}
bundle += '})();\n';
// Add plugin metadata
bundle += '\nwindow.__MY_PLUGIN_PLUGIN__ = {\n';
bundle += ' manifest: {\n';
bundle += ' id: "my-plugin",\n';
bundle += ' name: "My Plugin",\n';
bundle += ' category: "TERMINAL",\n';
bundle += ' profileType: "MY_PLUGIN",\n';
bundle += ' supportedAuthTypes: ["N/A", "login", "secret"],\n';
bundle += ' secretTypes: ["LOGIN_PASSWORD"],\n';
bundle += ' },\n';
bundle += ' profileFormElement: "my-profile-form",\n';
bundle += '};\n';
fs.writeFileSync(path.join(OUT_DIR, 'index.js'), bundle, 'utf8');
console.log('Built frontend/index.js (' + (bundle.length / 1024).toFixed(1) + ' KB)');package.json:
{
"name": "my-plugin",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "node build.js"
},
"devDependencies": {
"some-frontend-lib": "^1.0.0"
}
}Build workflow:
# 1. Install build dependencies
npm install
# 2. Run build script
npm run build
# 3. The frontend/index.js is now ready for distributionDistribution: Zip the directory including frontend/index.js but excluding node_modules/. Users extract to ~/.yaet/plugins/my-plugin/. The app runs npm install --ignore-scripts on first launch to restore backend dependencies.
Bundled plugins ship with the app and are compiled into the Angular bundle at build time. No separate packaging is needed — just place the plugin directory under plugins/ and it is automatically discovered on the next npm run build or npm start.
The build script scripts/generate-plugin-barrel.js scans all plugins/*/manifest.json files and generates plugins/generated-plugin-registry.ts, which statically imports all plugin modules.
| Plugin Type | Build Step | Distribution | User Install |
|---|---|---|---|
| Simple external | None | Zip directory | Extract to ~/.yaet/plugins/<id>/ |
| External with deps | npm install --ignore-scripts (auto on startup) |
Zip directory (no node_modules/) |
Extract to ~/.yaet/plugins/<id>/ |
| External with frontend build | npm run build → frontend/index.js |
Zip directory (include frontend/index.js, no node_modules/) |
Extract to ~/.yaet/plugins/<id>/ |
| Bundled | npm run build (Angular build) |
Git commit to plugins/ |
Ships with app |
-
manifest.jsonwith correctid,category,profileType - Directory name matches
manifest.id -
backend/index.jswithregister(context)function - Backend connector uses
context.projectRequire()for npm deps (orpackage.jsonfor self-managed deps) - Frontend
index.jsexposeswindow.__<ID>_PLUGIN__metadata - IPC channels added to manifest
ipcsection - Plugin works without any core code changes
- Plugin must be explicitly enabled by user (disabled by default for security)
- All of the above, plus:
-
ProfileTypeenum entry - Session class (thin wrapper delegating to plugin service)
-
SessionServiceupdated to create the session - Build passes (
npx ng build)
- External plugin npm deps (option 1 — projectRequire): Use
context.projectRequire(moduleName)in your connector to resolve npm packages from the project'snode_modules. Directrequire()will fail for external plugins since they live in~/.yaet/plugins/. - External plugin npm deps (option 2 — self-managed): Add
package.jsonto your plugin directory.PluginManagerrunsnpm install --ignore-scriptsautomatically on startup. This is useful for build-time dependencies (e.g.spice-client) or when you need packages not available in the core project. Both approaches can be mixed — Node.js resolves from plugin'snode_modulesfirst, then falls through toprojectRequire. - Session view sharing: Terminal plugins (SSH, Telnet, Local, WinRM) all share
TerminalComponentfor the UI. External plugins reuse it automatically viaPluginSession. - Profile form sharing:
RemoteTerminalProfileFormComponentis shared between SSH, Telnet, WinRM. External terminal plugins use it automatically via the@defaultcase inprofile-form.component.html. - Preload whitelist: IPC channels are automatically added to the preload whitelist from the plugin manifest. No manual whitelist editing needed.
- terminalMap: If your plugin uses terminal output, register with
terminalMapso the sharedterminalHandler.jscan route input/resize. - sessionRegistry: Register sessions here for AI tool integration (
session_write,session_read). - Plugin id = directory name: PluginManager constructs backend path as
path.join(baseDir, id, backend). The directory MUST matchmanifest.id.