Plugin System
Build custom panels, toolbar buttons, menu items, IPC handlers, forge providers, and file decorations on top of Daintree with the experimental plugin system.
name values now require a scoped publisher.name format (for example acme.linear-context). Bare names like my-plugin are rejected at manifest load. If you're migrating from v0.5 or v0.6: - Rename your plugin's
nameto scoped form, then update everyactionIdthat referenced the old name. - Add an
engines.daintreesemver range so the loader can skip your plugin cleanly on incompatible versions (see Version Compatibility). - Remove any
rendererfield. It still parses for backward compatibility, but the loader ignores it and logs a deprecation warning.
permissions array is now a strict enum. Only the 12 built-in permission strings are accepted. Any unrecognized value fails manifest validation and skips the whole plugin. Declaring a high-risk permission also forces every action your plugin registers up to a confirmation prompt. See Permissions.activate(host) entry point that hands your plugin a scoped host API instead of the bare injected globals.Overview
The plugin system lets you add custom panels, toolbar buttons, menu items, IPC handlers, forge providers, and file decorations to Daintree. Plugins load from the filesystem at startup. Each plugin is a directory containing a plugin.json manifest that declares its metadata, entry points, and contributions.
There is no plugin marketplace and no in-app management UI. You install a plugin by placing it in the right directory and restarting Daintree. It's for developers who want to build custom tooling on top of Daintree's workspace. Daintree's own GitHub integration ships as a built-in plugin, so it's the reference for what a real forge and decoration provider looks like.
Installation
Plugins live in ~/.daintree/plugins/. Each plugin gets its own subdirectory, containing at minimum a plugin.json manifest file:
~/.daintree/plugins/
└── acme.my-plugin/
├── plugin.json
└── main.js # optional main process entry To install a plugin, create its directory under ~/.daintree/plugins/, add the manifest, then quit and relaunch Daintree. If the ~/.daintree/plugins/ directory doesn't exist, Daintree skips plugin loading silently, no errors.
Manifest Reference
The plugin.json manifest is the plugin's declaration. It defines who the plugin is, what it contributes, and where its code lives. Here's a complete example with every field:
{
"name": "acme.my-plugin",
"version": "1.0.0",
"displayName": "My Plugin",
"description": "A sample plugin demonstrating the available contribution types.",
"engines": {
"daintree": "^0.12.0"
},
"main": "main.js",
"permissions": ["fs:project-read", "network:fetch", "git:read"],
"contributes": {
"panels": [
{
"id": "viewer",
"name": "Custom Viewer",
"iconId": "eye",
"color": "#8B5CF6"
}
],
"toolbarButtons": [
{
"id": "open-viewer",
"label": "Open Viewer",
"iconId": "eye",
"actionId": "acme.my-plugin.open-viewer",
"priority": 3
}
],
"menuItems": [
{
"label": "Open Custom Viewer",
"actionId": "open-viewer",
"location": "view",
"accelerator": "CommandOrControl+Shift+V"
}
],
"forgeProviders": [
{
"id": "my-forge",
"name": "My Forge",
"matches": ["git.example.com"]
}
],
"fileDecorationProviders": [
{
"id": "review-badges",
"scopes": ["worktree-diff:*"]
}
]
}
} Root Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Scoped plugin identifier in publisher.name format. Must match /^[a-z0-9]+(?:-[a-z0-9]+)*.[a-z0-9]+(?:-[a-z0-9]+)*$/, max 64 characters. Lowercase segments only, hyphens allowed within a segment, exactly one dot separator. Used as the namespace prefix for every contributed ID. See Plugin ID Format. |
version | string | Yes | Plugin version string (any format, minimum 1 character). |
displayName | string | No | Human-readable name shown in UI contexts. |
description | string | No | Short description of what the plugin does. |
engines | object | No | Host version constraints. One field for now, engines.daintree, which accepts any valid semver range (for example ^0.12.0 or >=0.12.0 <0.14.0). Incompatible plugins are skipped with a user-visible toast. Plugins without this field still load, but log a warning. See Version Compatibility. |
main | string | No | Relative path to a Node.js entry file that runs in the Electron main process. The module exports an activate(host) function. See Activation & Host API. |
permissions | string[] | No | Capabilities the plugin intends to use. Strict enum: only the 12 built-in permission strings are accepted, and any unrecognized value fails validation. Defaults to empty. See Permissions. |
renderer | string | No | Deprecated. Still parsed for backward compatibility, but ignored at load time. The loader logs a deprecation warning and continues. Leave it out of new manifests. See Renderer Entry. |
contributes | object | No | Container for every contribution array: panels, toolbarButtons, menuItems, forgeProviders, and fileDecorationProviders. Defaults to empty. The keys views and mcpServers are reserved (validated, but ignored at runtime, see Reserved Contribution Keys). |
Plugin ID Format
Plugin name values use a scoped publisher.name format. The publisher segment identifies the author or organization, and the name segment identifies the plugin. Both segments are lowercase, may contain digits, and may use hyphens internally. A single dot separates them. The whole string is capped at 64 characters.
The validation regex is /^[a-z0-9]+(?:-[a-z0-9]+)*.[a-z0-9]+(?:-[a-z0-9]+)*$/. A manifest that fails this check is rejected at load with:
Plugin name must be in publisher.name format (e.g. "acme.linear-context") | Example | Valid | Reason |
|---|---|---|
acme.linear-context | Yes | Publisher and name segments, hyphen inside the name segment. |
daintreehq.dev-tools | Yes | Standard scoped form. |
daintree-hq.my-cool-plugin | Yes | Hyphens allowed in both segments. |
a.b | Yes | Minimum valid form: one character per segment. |
my-plugin | No | Bare name with no publisher. Add a scope (for example acme.my-plugin). |
Acme.linear-context | No | Uppercase letters are not allowed in either segment. |
acme.team.tools | No | Only one dot separator is allowed. Pick a two-segment form. |
acme.-foo / acme.foo- | No | Segments cannot start or end with a hyphen. |
acme..tools | No | Empty segment between dots. |
acme_tools / acme/tools | No | Underscores and slashes are not part of the pattern. |
The plugin's name is the namespace prefix for every contributed ID. A plugin called acme.my-plugin that contributes a panel with id: "viewer" registers as panel kind acme.my-plugin.viewer. Contribution IDs inside the manifest (panel id, toolbar button id) still use the looser safe-ID pattern, since they're namespaced under the plugin name.
Version Compatibility
The optional engines.daintree field declares which Daintree versions a plugin supports. It accepts any valid semver range (^0.12.0, ~0.12.2, >=0.12.0 <0.14.0, *). An invalid range string fails the Zod schema validation step (step 1 of the loading lifecycle) and the manifest is rejected outright, the same way a bad name value is. That's separate from the version-incompatibility skip at step 2, which applies when the range is valid but doesn't satisfy the running Daintree version.
On startup, the loader evaluates semver.satisfies(appVersion, range, { includePrerelease: true }). If the current Daintree version satisfies the range, the plugin loads normally. If it doesn't, the plugin is skipped and the user sees a toast:
Plugin "My Plugin" requires Daintree ^0.12.0 but current version is 0.11.4. A plugin that omits engines.daintree still loads, but the loader logs a warning recommending the field. Treat that warning as a todo. Every plugin should declare a compatibility range before shipping.
One edge case to know: with includePrerelease: true, a prerelease version like 0.12.1-rc.1 satisfies ^0.12.0, but a prerelease below a range's floor (0.12.0-rc.1 against >=0.12.0) does not. If you need prerelease support, declare it explicitly in the range.
engines.daintree to the Daintree version you develop against, using a caret range to allow compatible updates (for example ^0.12.0). That gives users a clear "tested against" signal and lets the loader skip cleanly on older installs, instead of your plugin silently misbehaving.Permissions
The optional permissions array declares the capabilities a plugin intends to use. It's a strict enum: only the 12 built-in permission strings below are accepted, and the array defaults to empty. Any value outside the list fails Zod validation, which rejects the whole manifest and skips the plugin. There's no partial acceptance. One typo in the array takes the entire plugin offline.
| Permission | Intended capability |
|---|---|
fs:project-read | Read files in the active project. |
fs:project-write | Write files in the active project. |
fs:user-data-read | Read the plugin's own user-data storage. |
fs:user-data-write | Write the plugin's own user-data storage. |
network:fetch | Make outbound network requests. |
agent:read | Read agent state and output. |
agent:invoke | Start or drive agents. |
git:read | Read Git state (status, log, diffs). |
git:write | Run Git operations that change history or the working tree. |
clipboard:read | Read the system clipboard. |
clipboard:write | Write the system clipboard. |
shell:exec | Execute shell commands. |
shell:exec, git:write, fs:project-write, fs:user-data-write, agent:invoke) forces every action your plugin registers up to a confirmation prompt, regardless of the danger level the action declared. The host can raise danger this way but never lowers a declared confirm, and read-only or reversible permissions don't trigger it.Declare permissions honestly even though they aren't enforced as isolation. The list is logged on load, shows users what a plugin reaches for, and drives the action-confirmation behavior above. See Actions and Keyboard Shortcuts for how effectiveDanger works.
Panels
Panel contributions register new panel kinds in the panel palette (Cmd+N). Each contributed panel becomes a selectable type alongside Daintree's built-in panels. The final panel kind ID is namespaced as {pluginName}.{id}, so a plugin named acme.my-plugin with a panel id: "viewer" registers as acme.my-plugin.viewer.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | Panel kind identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters. | |
name | string | Yes | Display name shown in the panel palette and tab headers. | |
iconId | string | Yes | Icon identifier string used in the palette and tab. | |
color | string | Yes | Accent color for the panel (hex string). | |
hasPty | boolean | No | false | Whether the panel uses a PTY process. |
canRestart | boolean | No | false | Whether the restart button is shown in the panel header. |
canConvert | boolean | No | false | Whether the panel can be converted to other panel types. |
showInPalette | boolean | No | true | Whether the panel kind appears in the panel palette. Set to false for panel types that should only be created programmatically. |
hasPty. A PTY-backed kind (hasPty: true) renders through Daintree's generic terminal pane, so a plugin can ship a working terminal-style panel. A non-PTY kind has no generic renderer yet and falls back to the placeholder until a per-kind component exists. Renderer-side plugin scripts are deprecated with no replacement, so there's no API for drawing custom non-terminal panel content today.For more on how panels work in Daintree, see Terminals & Panels.
Toolbar Buttons
Toolbar button contributions add buttons to Daintree's main toolbar. Plugin buttons use the same overflow system as built-in buttons. Users can show or hide plugin buttons in Settings > Toolbar. The final button ID is namespaced as plugin.{pluginName}.{id}, so acme.my-plugin's open-viewer button registers as plugin.acme.my-plugin.open-viewer.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | Button identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters. | |
label | string | Yes | Tooltip text and overflow menu label. | |
iconId | string | Yes | Icon identifier string. | |
actionId | string | Yes | Action ID dispatched when the button is clicked. Can be a plugin-defined action ID. | |
priority | 1–5 | No | 3 | Overflow priority. 1 = last to overflow (stays visible longest), 5 = first to overflow. |
For details on toolbar overflow behavior and priority tiers, see UI Layout.
Menu Items
Menu item contributions add entries to Daintree's application menu. Each item names which menu it belongs to and an action ID to dispatch when selected. The action is dispatched with a plugin: prefix, so an actionId of "open-viewer" fires as plugin:open-viewer.
| Field | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Display label in the menu. |
actionId | string | Yes | Action ID dispatched on selection (prefixed with plugin: by the app menu). |
location | string | Yes | Which menu to add the item to: terminal, file, view, or help. |
accelerator | string | No | Keyboard shortcut in Electron accelerator format (e.g. CommandOrControl+Shift+P). |
Activation & Host API
The main field points to a Node.js module that runs in Electron's main process. The module exports an activate(host) function. Daintree calls it once at startup, after every manifest contribution (panels, buttons, menus, forge and decoration descriptors) has been registered. The host argument is a PluginHostApi scoped to your plugin. It's already bound to your plugin ID, so you don't pass an ID into any of its methods.
// main.js
// Daintree imports this module and calls activate(host) once at startup.
export async function activate(host) {
// Register IPC handlers the renderer can invoke. No pluginId argument:
// the host is already scoped to this plugin.
host.registerHandler('get-data', async (arg1, arg2) => {
return await fetchSomething(arg1, arg2);
});
// React to the active worktree changing.
const off = host.onDidChangeActiveWorktree((wt) => {
if (wt) host.broadcastToRenderer('active-worktree', { name: wt.name });
});
// Optional: return a cleanup function, run when the plugin unloads.
return () => {
off();
};
} activate(host) can return a cleanup function (sync, or a promise that resolves to one). Daintree calls it when the plugin unloads. Subscriptions and providers you register through the host are disposed automatically on unload, so the cleanup function is for anything else you set up yourself.
main entry runs with full Node.js access in Electron's main process. Plugin code has the same capabilities as any Node.js module: filesystem access, network requests, child processes. Treat a plugin the way you'd treat any npm dependency you install. The permissions array documents what the plugin reaches for, but does not restrict it.PluginHostApi
The host object passed to activate is everything a plugin needs at runtime:
| Member | Description |
|---|---|
pluginId | Readonly. The plugin's scoped name. |
registerHandler(channel, handler) | Registers a main-process IPC handler the renderer can invoke. No pluginId argument: the host is already scoped to your plugin. |
broadcastToRenderer(channel, payload) | Pushes an event to the renderer. Channel names must not contain colons; the event is emitted on plugin:{pluginId}:{channel}. |
getActiveWorktree() | Returns the current worktree as a read-only PluginWorktreeSnapshot, or null. |
getWorktrees() | Returns all worktrees across projects as PluginWorktreeSnapshot[]. |
onDidChangeActiveWorktree(cb) | Subscribes to active-worktree changes. Returns an unsubscribe function. |
onDidChangeWorktrees(cb) | Subscribes to the worktree list changing. Returns an unsubscribe function. |
registerForgeProvider(descriptor, impl) | Binds a forge provider implementation to a declared manifest entry. See Forge Providers. |
registerFileDecorationProvider(descriptor, impl) | Binds a file decoration provider to a declared manifest entry. See File Decorations. |
invalidateFileDecorations(scope, paths?) | Signals that decorations changed so the renderer re-pulls them. Optionally scoped to specific paths. |
Activation lifecycle and timeouts
The host is created per-plugin and revoked the moment activate() resolves or times out. After revocation, calling any host method throws Plugin "X" host revoked: .... The practical rule: register everything (handlers, providers, listeners) during activate(), not after.
activate(), or at least before it returns. The host is revoked as soon as activate() settles, so a setTimeout that calls host.registerHandler later will throw. The one exception is invalidateFileDecorations, which is meant to fire from later callbacks and quietly becomes a no-op after unload.Activation has a 5000ms timeout. If activate() hasn't resolved by then, it's rejected with Plugin "X" activate() timed out after 5000ms. If activate() throws, the error is logged and the plugin's static manifest contributions (panels, toolbar buttons, menu items, forge and decoration descriptors) stay registered. The plugin is partially functional: its declared UI surfaces exist, but its runtime logic and provider implementations don't.
The older global registerPluginHandler(pluginId, channel, handler) still exists as a low-level forwarder for backward compatibility, but activate(host) with host.registerHandler is the path to use. Channel names still can't contain colons, and re-registering the same channel replaces the previous handler.
Workspace Context
Plugins read workspace state through the host. getActiveWorktree() returns the first worktree marked current across all projects, and getWorktrees() returns the full list. Subscribe to changes with onDidChangeActiveWorktree and onDidChangeWorktrees. Both return unsubscribe functions and are disposed automatically on unload. Subscriptions registered during early-boot activation are queued and replayed, so a callback never misses the events that fired before the workspace was ready.
Everything you get back is a PluginWorktreeSnapshot: a read-only, deep-frozen object. Mutating it throws. If you need to keep state, copy what you need out of the snapshot.
PluginWorktreeSnapshot
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier for the worktree. |
worktreeId | string | The worktree's own ID within its project. |
path | string | Absolute filesystem path to the worktree. |
name | string | Display name. |
isCurrent | boolean | Whether this is the active worktree. |
branch | string? | Current branch name, if on a branch. |
isMainWorktree | boolean? | Whether this is the project's main worktree. |
aheadCount | number? | Commits ahead of the upstream. |
behindCount | number? | Commits behind the upstream. |
linked | object | null | Linked forge resources (issue, PR). See below. |
mood | string? | One of stable, active, stale, or error. |
lastActivityTimestamp | number? | Timestamp of the last activity in the worktree. |
createdAt | number? | Timestamp of when the worktree was created. |
Linked issues and pull requests
The linked field is provider-agnostic. It's either null or an object shaped like this:
linked: {
providerId: string,
issue?: { ref: string, title?: string },
pr?: {
ref: string,
title?: string,
url: string,
state: string,
ciStatus?: string,
baseRef?: string
}
} | null This replaced the old GitHub-shaped flat fields (the issueNumber / prUrl style). The normalized shape carries a providerId, so the same structure works for GitHub and for any forge provider a plugin contributes.
Forge Providers
A forge provider teaches Daintree how to talk to a Git host: issues, pull requests, reviews, and the URLs that tie them together. The built-in GitHub integration is itself a forge provider, so contributing one puts your host on equal footing with GitHub in the UI. New in v0.12.
Settings > Code Forge is where these integrations live. It's the settings tab that hosts every Git forge Daintree knows about: the built-in GitHub provider and any provider a plugin contributes, each selectable from a provider dropdown. (See Code Forge for the built-in provider.)
Forge providers use a two-phase model. The descriptor is declared in contributes.forgeProviders and registers eagerly at load, before any of your code runs. That's what populates Settings > Code Forge and the URL-routing table. The implementation binds later, during activate(), through host.registerForgeProvider(descriptor, impl). The descriptor.id has to match a declared manifest entry, or registration is rejected.
ForgeProviderContribution fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Safe-ID, max 64 characters. Namespaced at runtime as {pluginId}.{id}. (The built-in GitHub provider uses the bare id github.) |
name | string | Yes | Display label shown in Settings > Code Forge. |
matches | string[] | Yes | Hostnames this provider handles. At least one is required. Exact-match only, see below. |
capabilities | string[] | No | Advisory hints about what the provider supports. The host does not interpret these. |
credentialFields | object[] | No | Drives the credential form in Settings. Each field is { id, label, type, placeholder?, helpText? }. |
settingsScopeRef | string | No | Optional reference to a settings scope. |
viewRefs | string[] | No | Optional references to contributed views. |
matches is exact normalized-hostname equality, not glob or wildcard matching. git.example.com matches git.example.com and nothing else. There's no suffix or pattern matching, and the only normalization is a leading www. strip plus lowercasing. List every hostname you support as its own entry. When two providers claim the same hostname, the first one registered wins.The ForgeProviderImpl contract
The implementation you pass to host.registerForgeProvider is a large runtime contract: authentication, CRUD on issues and pull requests, URL builders, and a set of optional capabilities (reviews, approvals, releases, project boards, milestones). It's big enough that the type definition is the real reference. What you need up front is the manifest descriptor above plus the binding call. The GitHub built-in plugin is the working example to model against. Re-binding the same id overwrites the previous implementation.
Settings integration
A contributed provider appears under Settings > Code Forge, selected from a provider dropdown alongside the built-in GitHub entry. Daintree builds the credential form from your credentialFields: each field renders a labeled input (a password type is masked, anything else is plain text) with its optional placeholder and help text. If you declare no credential fields, the form shows "No configuration needed". The primary field is validated against the provider before saving, so a bad token surfaces immediately instead of failing silently later. A successful save shows a "{name} connected" confirmation.
File Decorations
File decoration providers attach small badges, tooltips, and colors to files. They're modeled on VS Code's FileDecorationProvider, but narrower: Daintree renders decorations in exactly one place, the Review Hub diff file list, as per-file badges. The built-in GitHub plugin contributes the worktree-diff-review provider that shows review-thread counts there. New in v0.12.
propagate field. Build against the diff-list use case. Anything broader isn't wired up yet. See Review Hub for where these badges appear.Provider contribution
The manifest entry is small. Each provider declares an id (safe-ID, max 64 characters) and a scopes array (at least one entry). A scope is an exact string, a single trailing-* prefix wildcard like worktree-diff:*, or a bare * that matches anything. The Review Hub requests decorations on the scope worktree-diff:{worktreePath}, so a provider declaring worktree-diff:* receives those requests.
Binding the implementation
During activate(), bind the implementation with host.registerFileDecorationProvider(descriptor, impl). The implementation provides one method:
impl.provideDecorations(scope, paths)
=> Promise<Record<string, FileDecoration>> Daintree calls it with the scope and the exact paths it wants decorated, and you return a map from path to decoration. Only requested paths are honored. You can't widen the set. When your data changes, call host.invalidateFileDecorations(scope, paths?) and Daintree re-pulls.
The FileDecoration shape
A FileDecoration has three optional fields: badge (a short string), tooltip (hover text), and color (an opaque token string passed straight through to the renderer's className).
badge to two characters or fewer, following the VS Code convention. The host doesn't hard-truncate longer strings, but the diff list is laid out for one or two characters and anything longer will look wrong.Each provider has a 3000ms budget per pull. A provider that throws, rejects, or times out is logged and skipped. It doesn't blank out the row. When multiple providers cover the same scope, results merge field by field in plugin load order, first writer wins for each of badge, tooltip, and color independently.
Reserved Contribution Keys
contributes.views and contributes.mcpServers. The schema validates them so a manifest that declares them doesn't fail to parse, but the loader ignores them at runtime and logs contributes.{views|mcpServers} is not yet implemented and will be ignored. Declaring them is harmless and does nothing useful yet. Don't build against them.Renderer Entry
renderer field is deprecated as of v0.7. It still passes manifest validation so older plugins don't fail to parse, but the loader ignores it and logs Plugin "..." uses deprecated 'renderer' field. This field is no longer supported and will be ignored. Renderer-side plugin scripts are no longer supported. Move all plugin logic into the main process entry and drive the UI through IPC.Plugins still interact with the renderer through window.api.plugin. Use plugin.invoke to call into your main-process handlers and plugin.list to inspect what's loaded. The resolvedRenderer field that older documentation referenced no longer exists on the returned plugin info.
Renderer API
The renderer talks to plugin main-process code through window.api.plugin:
| Method | Description |
|---|---|
plugin.list() | Returns an array of loaded plugin info including manifest data and directory path. |
plugin.invoke(pluginId, channel, ...args) | Calls a registered main-process handler and returns the result. This is the primary renderer-to-main communication channel. |
plugin.on(pluginId, channel, callback) | Subscribes to events on a plugin channel. Returns an unsubscribe function. |
plugin.toolbarButtons() | Returns the list of toolbar button configurations registered by all plugins. |
plugin.menuItems() | Returns the list of menu item configurations registered by all plugins. |
plugin.invoke for request/response patterns. For the other direction, the main process pushes events to the renderer with host.broadcastToRenderer(channel, payload) during activate(), and the renderer subscribes to those events with plugin.on.Panel State Persistence
Panel instances have an extensionState field: an opaque Record<string, unknown> a plugin can use to store arbitrary data. This state persists through session save/restore cycles and project switches, so your plugin's per-panel data survives a restart.
The setter API for writing extension state isn't finalized yet in the experimental system. The field is readable from panel instances, and the data round-trips correctly through save/restore. How plugins write back to it will be documented once the API stabilizes.
Extension state is also available at the project level, via extensionState on the project object. That gives plugins a place to store project-scoped data that isn't tied to a specific panel.
Placeholder Panels
When a panel's kind is no longer registered, because the plugin that contributed it was disabled or uninstalled, Daintree doesn't drop the panel. It renders a placeholder in its place: a puzzle-piece icon, a "Plugin unavailable" heading, the plugin id in monospace, and a "Remove panel" button. The user can clear it out or leave it.
The panel's extensionState is preserved on disk while the placeholder is showing. Re-enable or reinstall the plugin and the panel comes back exactly as it was, no data lost. This is the disabled or uninstalled case specifically. A plugin that fails manifest validation never registers its panel kind in the first place, so there's nothing to stand in for and no placeholder appears.
Actions and Keyboard Shortcuts
Daintree's ActionId and KeyAction types are open unions, so a plugin can define custom action identifiers beyond the built-in set. Any string works as an action ID, and plugin-defined actions plug into Daintree's action palette and keybinding system.
Toolbar button actionId values are dispatched directly when clicked. Menu item actionId values are dispatched with a plugin: prefix. Both can be bound to keyboard shortcuts through Settings > Keyboard.
Action ID Validation
After the renderer's action registry finishes populating, it pushes the full list of known action IDs back to the main process. Daintree then checks every actionId referenced by a plugin's toolbar buttons and menu items against that set. Anything unrecognized triggers a console warning:
[Plugin] Unknown actionId "acme.my-plugin.opne-viewer" on toolbar button "open-viewer" (plugin: acme.my-plugin)
[Plugin] Unknown actionId "missing-action" on menu item "Open Viewer" (plugin: acme.my-plugin) The log shows the actionId exactly as declared, so the form matches the contribution: a toolbar button references a scoped action ID, while a menu item uses the bare ID that the app menu prefixes with plugin:. This check runs once per session, so multi-window setups don't double-log. Non-array payloads are ignored defensively.
Registering actions at runtime
Plugins that register actions at runtime follow a stricter set of rules than the manifest fields above:
- ID format. An action ID must match
/^[a-z0-9][a-z0-9-]*.[a-z0-9][a-zA-Z0-9._-]*$/and be prefixed with the plugin's own id. An action fromacme.my-pluginlooks likeacme.my-plugin.open-viewer. - Danger level. A plugin can register
safeorconfirmactions. Registeringrestrictedis rejected withPlugins may only register safe or confirm actions. - Effective danger. The renderer reads
effectiveDanger, not the declareddanger, and fails safe toconfirmwhen it's absent. As covered under Permissions, declaring a high-risk permission raises every action'seffectiveDangertoconfirmregardless of what the action declared.
Security
Daintree validates that the main path doesn't escape the plugin's directory. It's resolved with path.resolve and checked against the plugin directory prefix. If the path escapes, the entry is silently dropped, but the plugin's other contributions still load. The plugin name field is constrained to the scoped lowercase publisher.name pattern, which rules out path separators, whitespace, and uppercase characters in the namespace prefix.
Beyond path validation, there is no sandboxing. Main process plugin code runs with full Node.js capabilities in Electron's main process. The permissions array is disclosure only: it documents what a plugin reaches for and drives action confirmation prompts, but it does not isolate or restrict the plugin. Only install plugins you trust. It's the same trust model as installing an npm package or a VS Code extension.
Built-in Plugins
Some plugins ship with Daintree itself. They carry an isBuiltin flag and load from the app bundle before any user plugins, so a user plugin can't hijack a first-party name. The built-in namespace stays reserved even when the plugin is turned off.
A built-in plugin can be disabled but not uninstalled. Disabling is stored in store.plugins.disabledBuiltins and takes effect on the next launch. The built-in GitHub plugin is the reference example of a real forge and decoration provider. If you're building either, it's the implementation to study.
Loading Lifecycle
When Daintree starts, the plugin loader processes each subdirectory in ~/.daintree/plugins/ through these steps, in order:
- Validate manifest by parsing
plugin.jsonagainst the Zod schema. An invalid manifest (including a name that doesn't match the scopedpublisher.namepattern, or an unrecognizedpermissionsvalue) skips the plugin entirely. - Check
engines.daintreeagainst the running app version. An incompatible plugin is skipped with a user-visible error toast. A plugin without the field loads with a console warning. - Warn on deprecated fields. If the manifest still sets
renderer, the loader logs a deprecation warning and ignores the field. Declaredpermissionsare logged here too. - Resolve entry paths for
main, checking they don't escape the plugin directory. - Register panel kinds from
contributes.panelsinto the panel kind registry. - Register toolbar buttons from
contributes.toolbarButtonsinto the toolbar button registry. - Register menu items from
contributes.menuItemsinto the menu registry. - Register forge and decoration descriptors from
contributes.forgeProvidersandcontributes.fileDecorationProviders. These register eagerly so Settings and routing are populated before any plugin code runs. Implementations bind later, during activation. - Call
activate(host)from the dynamically imported main entry. The host is revoked onceactivate()resolves or hits its 5000ms timeout. If it throws or times out, the static contributions from the earlier steps stay active. - Validate action IDs once the renderer's action registry populates. Unknown
actionIdvalues on toolbar buttons and menu items log a console warning. The plugin is never blocked.
The whole process runs once at startup. Plugins load in parallel through Promise.allSettled, so one failing plugin doesn't block the others. Daintree can also unload a plugin cleanly at runtime through an internal unloadPlugin(pluginId) service. It removes the plugin's IPC handlers; unregisters its actions, panel kinds, toolbar buttons, menu items, forge descriptors and implementations, and decoration descriptors and implementations; disposes every onDidChange* subscription; calls the cleanup function returned by activate(); and broadcasts a decorations-changed event so any stale decorations clear. Daintree uses this for its own lifecycle management. There's no IPC hook for a plugin to trigger its own unload yet.
Limitations
The plugin system is in early development. Constraints to know about right now:
- No hot-reload. Any change to a plugin needs a full restart of Daintree.
- No sandbox. Main process plugins run with full Node.js access. The
permissionsarray is disclosure only, not enforced isolation. The only thing it enforces is action confirmation prompts. - No marketplace. Plugins are installed by hand, by placing files in
~/.daintree/plugins/. - No user-facing disable/unload for user plugins. A clean unload path exists internally (
unloadPlugin) and Daintree uses it during its own lifecycle, but there's no in-app control or IPC surface for a user plugin to disable itself. The only way to remove a user plugin today is to delete its directory and restart. (Built-in plugins can be disabled, see Built-in Plugins.) - File decorations are diff-list only. Decorations render in the Review Hub diff file list and nowhere else. There's no general file-tree, tab, or breadcrumb decoration, and no
propagatefield. - Duplicate plugin names are rejected. Two plugins can't share a
name. Built-ins load first and reserve their namespace, so a user plugin that collides with a registered name is skipped rather than silently overwriting it. - No renderer-side entry points. The
rendererfield is deprecated and ignored at load time. All plugin logic runs in the main process and drives the renderer through IPC.