Host API
The runtime surface a Daintree plugin's activate function receives: the fully async PluginHostApi, the revoke-guarded and live halves, panel pushes and badges, worktree and agent observation, capability-gated process, filesystem, git and clipboard access, native prompts, and the view component contract.
The host API is the runtime surface a plugin's activate function receives. It exposes Daintree's state and lets a plugin register behavior that the manifest cannot describe statically. Where the manifest and contribution points declare what a plugin is, the host API is what it does once it is running.
Types come from @daintreehq/plugin-sdk. That package is not published to npm yet. See Building & Distributing for what that means in practice.
Filtering needs JavaScript. The contents list works without it, and so does your browser's find-in-page — every section on this page is in the document.
The activation contract
A plugin's main module exports an activate function. It may return a cleanup function, which runs when the plugin is unloaded: on hot reload, disable, uninstall, or app quit.
import type { PluginHostApi } from "@daintreehq/plugin-sdk";
export async function activate(host: PluginHostApi) {
await host.registerAction(
{ id: "sync-now", title: "Sync Now", kind: "command", danger: "safe" },
async (args) => ({ ok: true })
);
return () => {
// optional cleanup: runs on unload, uninstall, dev reload, or app quit
};
} Activation is lazy by default. Nothing imports main until one of the plugin's contributions is first used: a command is dispatched, a contributed panel is opened, a forge operation reaches one of its providers, or a file-decoration pull matches one of its scopes. Listing "onStartupFinished" in activationEvents is the single eager trigger, and it fires once startup has settled rather than on the boot path.
The activation timeout is 5000 ms. If activate has not resolved by then, the plugin is marked failed and a toast surfaces. Keep activation to registration and cheap setup; defer real work into command handlers and subscription callbacks.
Anything registered through the host is torn down for you. If activate throws after it has already registered handlers, actions, or subscriptions, the host rolls all of them back. The rollback is synchronous and host-owned, and a user-installed plugin gets the same guarantee from its worker being destroyed. Do not try to undo your own registrations in a catch; let the error propagate.
The API surface
Roughly thirty members, grouped by what they do. The authoritative definition lives in the SDK's type surface.
interface PluginHostApi {
readonly pluginId: string;
// Registration: activation window only
registerAction(descriptor, handler): Promise<void>;
registerHandler(channel, schema, handler): Promise<void>;
broadcastToRenderer(channel, payload): Promise<void>;
registerForgeProvider(descriptor, impl): Promise<() => void>;
registerFileDecorationProvider(descriptor, impl): Promise<() => void>;
// Observation: subscribe during activate, react for the plugin's lifetime
getActiveWorktree(): Promise<PluginWorktreeSnapshot | null>;
getWorktrees(): Promise<PluginWorktreeSnapshot[]>;
getWorktreeStatus(path): Promise<PluginWorktreeStatus | null>;
onDidChangeActiveWorktree(cb): Promise<() => void>;
onDidChangeWorktrees(cb): Promise<() => void>;
getAgentState(): Promise<PluginAgentSnapshot | null>; // agent:read
onDidChangeAgentState(cb): Promise<() => void>; // agent:read
onDidChangePanelLifecycle(cb): Promise<() => void>;
// Live runtime surface: callable for the plugin's whole lifetime
postToPanel(channel, payload, panelId?): Promise<void>;
setPanelBadge(panelId, badge): Promise<void>;
invalidateFileDecorations(scope, paths?): Promise<void>;
dispatch(actionId, args?): Promise<ActionDispatchResult>;
readonly actions: PluginHostActionsApi;
sendToActiveAgent(text, options?): Promise<void>; // agent:input
showToast(options): Promise<void>;
showQuickPick(items, options?): Promise<...>;
showInputBox(options?): Promise<string | undefined>;
showConfirm(options): Promise<boolean>;
readonly settings: SettingsApi;
readonly storage: StorageApi;
readonly logger: PluginLogger;
readonly process: PluginProcessApi; // shell:exec
readonly fs: PluginFsApi; // fs:*
readonly git: PluginGitApi; // git:*
readonly clipboard: PluginClipboardApi; // clipboard:*
readonly system: PluginSystemApi; // fs:*
} The API is fully Promise-returning. This changed when plugins moved out of process: every host.* call is bridged over a MessagePort, so registrations resolve Promise<void> and the subscription methods resolve Promise<() => void> rather than returning a disposer synchronously. Always await a registration before assuming it took effect, and await a subscription to get its disposer. logger is the lone exception: its info / warn / error calls return void and never throw.
Revoke-guarded versus the live runtime surface
The single most load-bearing distinction in the API is when a method may be called. The host is revoked once activate() resolves or times out, and the surface splits in two along that line.
| Group | Methods | Rule |
|---|---|---|
| Revoke-guarded (activation window) | registerAction, registerHandler, broadcastToRenderer, registerForgeProvider, registerFileDecorationProvider, onDidChangeActiveWorktree, onDidChangeWorktrees, onDidChangeAgentState, onDidChangePanelLifecycle, settings.onDidChange, storage.onDidChange | Must be called during activate(). They throw once the host is revoked. |
| Live runtime surface | postToPanel, setPanelBadge, invalidateFileDecorations, getActiveWorktree, getWorktrees, getWorktreeStatus, getAgentState, dispatch, actions, sendToActiveAgent, showToast and the other prompts, process.spawn, fs.*, git.*, clipboard.*, system.*, settings.get / set, storage, logger | Callable for the plugin's whole lifetime, from timers and subscription callbacks. After unload they become a silent no-op (or, for process.spawn / fs / git, a rejection). |
Subscribing counts as an activation-window operation even though the callback fires much later. The shape a plugin should settle into is: register everything during activate(), then react for the rest of its life. postToPanel is the canonical post-activation push: activate() subscribes once, and the plugin streams live data into its panels forever afterward.
The two groups also report errors differently, which matters when you are debugging a silent failure.
// Activation-window methods throw synchronously on a bad descriptor
// or a revoked host.
await host.registerAction(descriptor, handler);
// Runtime-surface methods reject instead. Handle with .catch().
await host.postToPanel("build-status", status).catch((err) =>
host.logger.error(String(err))
); A liveness no-op (the plugin already unloaded) still resolves cleanly. Only a genuine validation error (an empty channel, a malformed badge) rejects. The split is encoded in the types, not just in prose: the revoke-guarded methods are factored into a PluginActivationApi sub-interface, each carrying a @throws tag that shows up on hover.
Actions and keyboard shortcuts
Most plugins expose their behavior as actions. Manifest-declared contributes.commands covers the static case; registerAction covers dynamic ids, runtime-driven categories, and any handler that needs the live host.
await host.registerAction(
{
id: "plan-from-issue", // no plugin prefix, the host adds it
title: "Plan From Issue",
description: "Turn a Linear issue into a branch and agent session.",
category: "Linear Planner",
kind: "command",
danger: "confirm", // "restricted" is rejected
keywords: ["linear", "plan"],
},
async (args) => {
return { ok: true };
}
); The descriptor id must not carry the plugin prefix. Daintree namespaces it to publisher.name.id at runtime. Re-registering an id replaces the prior registration. Colliding with a built-in action id is a load error.
A plugin may register safe or confirm actions; restricted is reserved for Daintree itself and rejected. The renderer reads effectiveDanger, not the declared danger. The host derives it and may only raise, never lower. Holding a high-risk capability raises every action the plugin registers, which is why requires exists on individual commands. See Trust & Capabilities.
Plugin actions are ordinary actions once registered: they appear in the launcher, they can be bound to a key from Settings > Keyboard, and a plugin can ship its own default bindings through contributes.keybindings.
dispatch runs any action (the plugin's own, another plugin's, or a built-in) through Daintree's audited dispatch path with a "plugin" source. There is no bypass: a confirm action returns CONFIRMATION_REQUIRED rather than executing.
const result = await host.dispatch("git.commit", { message: "fix: typo" });
if (!result.ok) {
// result.error.code: "RESTRICTED" | "CONFIRMATION_REQUIRED" | "PLUGIN_UNLOADED" | ...
}
// Pre-flight instead of guessing:
const entry = await host.actions.get("git.commit"); // null if unknown or restricted
if ((await host.actions.canDispatch("git.commit")) === "confirm") {
// warn the user before dispatch raises a confirm prompt
} Pushing data into panels
registerHandler and broadcastToRenderer are the low-level IPC pair, registered during activation. The typed registerHandler overload takes a schema with Zod args / result shapes plus a requires capability list; the host rejects registration when a required capability is missing, validates args before the handler runs, and validates the result before it returns. Failures carry a SCHEMA_ERROR: or PERMISSION_REQUIRED: prefix that the renderer hook discriminates on.
postToPanel is the post-activation sibling of broadcastToRenderer. It fans out over the same transport but stays callable for the plugin's whole lifetime, which makes it the channel a plugin actually streams over.
// main: from a timer, a poll, or a subscription callback
setInterval(async () => {
const status = await fetchBuildStatus();
await host.postToPanel("build-status", status); // every open instance
await host.postToPanel("build-status", status, panelId); // one instance
}, 5000); // view: bundled with @daintreehq/plugin-vite
import { usePluginEvent, usePluginPanelEvent } from "@daintreehq/plugin-sdk/react";
usePluginEvent<BuildStatus>(pluginId, "build-status", setBuildStatus);
usePluginPanelEvent<BuildStatus>(pluginId, "build-status", panelId, setBuildStatus); Omit panelId (or pass null) to broadcast to every open instance of the panel kind. Pass a non-empty panelId to target one instance, so two open copies of the same panel no longer both receive every push. An empty-string panelId is rejected. Delivery is fire-and-forget: there is no acknowledgement, and a panel that is not mounted simply does not receive the payload.
Panel badges
setPanelBadge overlays a small live indicator on a panel's title chrome, keyed by panel id. It exists so per-worktree or per-agent state (notes present, CI passing, review outstanding) surfaces without the user opening the panel.
await host.setPanelBadge(panelId, { kind: "dot", color: "warning", tooltip: "CI running" });
await host.setPanelBadge(panelId, { kind: "label", text: "3", color: "error" });
await host.setPanelBadge(panelId, null); // clear Two shapes: a bare status dot, or a short label whose text is capped at six characters host-side so it cannot overflow the header. Color is semantic (default, success, warning, error) rather than a raw hex value, so badges stay consistent across themes. Pass null to clear.
Panel lifecycle
onDidChangePanelLifecycle reports what happens to the plugin's own panel instances. No capability is required: the host resolves ownership from its own kind registry, so a plugin only ever receives events for kinds it contributed.
export async function activate(host: PluginHostApi) {
const servers = new Map<string, DevServer>();
await host.onDidChangePanelLifecycle((event) => {
if (event.phase === "removed") {
servers.get(event.panelId)?.stop();
servers.delete(event.panelId);
}
});
} | Phase | Meaning |
|---|---|
mounted | A view for this panel is rendered. |
hidden | The panel record is live but no view is mounted: a sibling pane was maximized, its dock tab is inactive, its project view is cached, or a retry is loading. Not a close. |
backgrounded | The panel is in the background location. |
trashed | Soft close. Recoverable from the trash bin, so not permanent disposal. |
restored | One-shot edge out of the trash, emitted immediately before the phase the panel landed in. |
removed | Terminal. The panel is gone and will not return under this id. |
render-failed | The current view attempt hit the host's error boundary. Cleared by a successful retry. |
This is where durable resources belong. Keep spawned processes and long-lived sessions in the worker, keyed by panelId, and release them on "removed". A plugin that instead tears down on unmount kills work the user still wants back: maximizing a neighboring pane unmounts every other grid panel, and that must not stop a running dev server.
On subscribe the host replays the current phase of every live panel the plugin owns. That matters because activation is lazy: opening a view is usually what triggers activate(), so without replay you would never see that panel's mounted. One-shot and terminal transitions are not replayed. A renderer being destroyed or evicted never synthesizes removed. A cached project view says nothing about whether the user closed anything.
Placeholder panels and persisted state
Panel instances carry an opaque extensionState bag that survives session save/restore and project switches. When a panel's kind is no longer registered, because the contributing plugin was disabled or uninstalled, Daintree does not drop the panel. It renders a placeholder with a puzzle icon, a "Plugin unavailable" heading, the plugin id, and a Remove panel button, and it keeps extensionState on disk. Re-enable or reinstall the plugin and the panel comes back exactly as it was; a restored panel also recovers on its own once the plugin activates, rather than staying stuck until it is closed and reopened.
A plugin that fails manifest validation never registers its panel kind at all, so there is nothing to stand in for and no placeholder appears.
Workspace context
Read-only access to Daintree's worktree state through an explicit allowlist, so internal shape changes do not leak into plugins.
const active = await host.getActiveWorktree(); // or null
const all = await host.getWorktrees();
const status = await host.getWorktreeStatus("/Users/me/project/.worktrees/feature-x");
const dispose = await host.onDidChangeActiveWorktree((snapshot) => {
if (snapshot) host.logger.info(`now on ${snapshot.name}`);
}); interface PluginWorktreeSnapshot {
readonly id: string;
readonly worktreeId: string;
readonly path: string;
readonly name: string;
readonly isCurrent: boolean;
readonly branch?: string;
readonly isMainWorktree?: boolean;
readonly aheadCount?: number;
readonly behindCount?: number;
readonly linked: PluginWorktreeLinked | null;
readonly status: PluginWorktreeStatus | null;
readonly mood?: "stable" | "active" | "stale" | "error";
readonly lastActivityTimestamp?: number | null;
readonly createdAt?: number;
}
interface PluginWorktreeLinked {
readonly providerId: string;
readonly issue?: { ref: ResourceRef; title?: string };
readonly pr?: {
ref: ResourceRef;
title?: string;
url: string;
state: NormalizedPRState;
ciStatus?: CIStatus;
baseRef?: string;
};
} Every snapshot is frozen; mutating one throws. getWorktrees() returns the worktrees of the project the plugin is acting for (the one in the focused window) and is empty when no window resolves.
linked is a provider-agnostic projection of the worktree's linked forge resources. It replaced the GitHub-shaped issueNumber / prUrl / prState fields entirely: route through linked.providerId and the shared ResourceRef shape so a plugin works against any forge provider. baseRef is the branch the PR merges into, which is what drives base-branch divergence display.
lastActivityTimestamp is the canonical activity time in epoch milliseconds: the newer of HEAD's committer time and the newest dirty file's modification time, or null when neither has a valid time.
status projects the host's already-polled worktree changes: a files array of path plus one of added, modified, deleted, untracked, renamed, a changedFileCount, and per-state counts. Reading it never shells out to a fresh git status. getWorktreeStatus(path) returns the same projection for a worktree you already have a path for, which is the common case when a context-menu dispatch handed you one. For staged-versus-unstaged detail or a real diff, use host.git.
Subscriptions registered during activate (before Daintree's worktree service is ready) are queued and replayed once it comes online, so a callback never misses events.
Agent state and agent input
getAgentState and onDidChangeAgentState observe agent activity and are gated on the agent:read capability.
sendToActiveAgent is the sanctioned injection path, gated on agent:input. The raw terminal.sendCommand action is closed to plugin dispatch specifically so plugins stop reinventing brittle terminal-selection heuristics.
// Stage the text for review: the default, no Enter appended
await host.sendToActiveAgent("Summarize the failing test and propose a fix.");
// Submit it immediately
await host.sendToActiveAgent("/compact", { submit: true }); The host resolves the target itself: the focused or visible agent terminal first, then a waiting agent, then the most recently active agent terminal in the active project. It never crosses a project boundary. submit defaults to false: the stage-only, default-safe mode that pastes the text for the user to review. First use raises a just-in-time consent prompt. It throws PERMISSION_REQUIRED: without the capability or on denial, and NO_ACTIVE_AGENT: when no agent terminal can receive input.
Native prompts
A command often needs one more piece of information mid-flight. Rather than every plugin building its own modal inside a panel, the host exposes Daintree's own dialogs, so a plugin prompt looks and behaves like a native one.
const picked = await host.showQuickPick(
[
{ id: "eng", label: "Engineering", description: "12 open issues" },
{ id: "des", label: "Design", detail: "Owned by @sam" },
],
{ title: "Pick a team", matchOnDescription: true }
);
const branch = await host.showInputBox({
title: "Branch name",
prompt: "Where should the worktree be created?",
validationPattern: "^[a-z0-9/-]+$",
validationMessage: "Lowercase, digits, slashes and hyphens only.",
});
const ok = await host.showConfirm({
title: "Delete 'stale-cache'?",
message: "The cache directory and its 412 entries are removed.",
confirmLabel: "Delete cache",
destructive: true,
}); showQuickPick: a searchable list. Items are plainid/label/description/detailstrings.canSelectManychanges the resolved value to an array;matchOnDescriptionwidens the fuzzy match beyond the label. Resolvesundefinedwhen dismissed.showInputBox: a single-line input with an optionalprompt,placeholder, pre-filledvalue, andpasswordmasking.validationPatternis checked client-side at submit time, so there is no per-keystroke IPC round trip; an invalid pattern is ignored rather than blocking the user.showConfirm: a yes/no gate.titleis required and should be a sentence-case question naming the entity. Setdestructivefor an irreversible action and giveconfirmLabela verb-noun label, never a bare "OK".showToast: fire-and-forget notification. The host prefixes the message with the plugin id so users can tell which plugin raised it. Messages are strings up to 2000 characters;durationMsis capped at 60 s. Toasts route through Daintree's normal notification path, so quiet hours and the inbox apply, and the rate-limit bucket is per plugin so a noisy plugin cannot suppress anyone else's toasts.
args only: there is no second host argument, because the host is revoked long before the handler is first dispatched. If your command needs to prompt, register it imperatively in activate() so the handler closes over the live host.Capability-gated surfaces
Four host surfaces are contained and audited rather than merely disclosed. Each is the sanctioned path for something a plugin would otherwise do by reaching for Node directly.
host.process: managed child processes
Gated on shell:exec. This is the one hard runtime gate on the whole API: a spawn from a plugin that did not declare the capability rejects with PERMISSION_REQUIRED:.
const handle = await host.process.spawn("npm", {
args: ["run", "dev"],
cwd: "/path/to/project", // defaults to the active worktree, then the host cwd
env: { PORT: "5173" }, // applied over a minimal allowlist, not the host env
panelId, // omit to broadcast the stream to every panel
});
handle.onData(({ stream, chunk }) => parseLine(chunk));
handle.onExit(({ exitCode, signal }) => host.logger.info("exited", { exitCode, signal }));
handle.onCrash(() => host.showToast({ message: "Dev server crashed", type: "error" }));
await handle.restart();
handle.kill(); Argv is passed verbatim through no shell, so there is no shell-injection surface. The handle carries a stable id, kill() (clean SIGTERM, then SIGKILL after a grace period), restart() (same command, same id, incremented restart counter), and onExit / onCrash subscriptions carrying the real exit code and signal. onCrash fires only on a termination you did not ask for. Output reaches the plugin's panels over postToPanel("process", …) keyed by handle id, and the plugin's own code via onData, which buffers output produced before the first subscriber attaches so a daemon that greets immediately is not missed.
Interactive processes get a real PTY. Pass mode: "pty" and the handle widens with write() and resize().
const pty = await host.process.spawn("claude", {
mode: "pty",
args: ["--resume"],
cols: 120,
rows: 40,
});
pty.write("continue\n");
pty.resize(160, 48); A pseudo-terminal merges stdout and stderr, so PTY output arrives as a single data stream rather than the stdout / stderr pair. A panel rendering process output should handle all three kinds. The PTY is allocated in Daintree's crash-isolated pty-host process, so a native failure cannot take the app down. A resize() issued while a restart() is still allocating the replacement is retained and folded into the new PTY's initial size.
The child does not inherit Daintree's environment. It is built from a fixed allowlist of essentials (PATH, HOME, locale, temp directory, and the Windows keys a child cannot run without) plus whatever the call passes in env. The main process's tokens therefore never leak into a shell:exec child; anything else the command needs must be handed to it explicitly. cwd is a process concern, not a filesystem scope: it is not contained to scopes.fs.allowedPaths.
Every spawned process is tied to the plugin's lifetime. On unload, disable, or revoke the host terminates all of them, so a dev server cannot leak past a reload. A per-plugin concurrency cap bounds how many run at once; a spawn past the cap rejects rather than queueing. Spawns are recorded in the plugin audit trail.
host.fs and host.git
const text = await host.fs.readFile("/Users/me/.acme/data/notes.md");
await host.fs.writeFile("/Users/me/.acme/data/out.json", JSON.stringify(result));
const dispose = await host.fs.watch(["/Users/me/.acme/data"], (changedPath) => { /* … */ });
const status = await host.git.status("/Users/me/project");
await host.git.add("/Users/me/project", ["src/index.ts"]); // worktree-relative paths only
const { commit, preview } = await host.git.commit("/Users/me/project", {
message: "fix: typo", // required. There is no derived-message fallback
}); Both are contained: every path argument is realpath-resolved against the plugin's declared scopes.fs.allowedPaths, and a traversal or a symlink that escapes a root rejects with PATH_NOT_ALLOWED:. Reads gate on fs:project-read / fs:user-data-read, writes on the matching -write token. Unlike the app's internal file read, host.fs.readFile carries no size or binary cap. It is a deliberate plugin API. Writes are audited, and watchers are torn down on unload.
host.git runs over Daintree's existing hardened git layer, scoped to a worktree inside the same allowed paths. Reads gate on git:read, mutations on git:write. Pathspecs must be worktree-relative: an absolute path, a .. segment, or git pathspec magic is rejected, because git would otherwise resolve them against the whole repository. Paths are matched literally, not as globs, so a filename containing * or brackets resolves to itself; expand any pattern yourself. commit enforces the change-preview safeguard at the host layer: it refuses without an explicit non-empty message and computes the real staged diff as a preview before mutating, returning it so your UI can show what happened.
host.clipboard and host.system
host.clipboard runs in the main process, so it works from a headless plugin with no focused document. writeText is gated on clipboard:write and rejects above 8 MiB; writeImage takes PNG bytes under the same token and rejects above 20 MiB. readText is gated on clipboard:read and resolves to an empty string when the clipboard holds non-text content. Reads are text-only by design: there is no readImage, readHtml, or readFiles, because the read side is where richer payload types would let a plugin pull out more than it declared.
host.system.openPath and showItemInFolder hand a file to the OS, scoped to the plugin's own filesystem roots plus its implicit plugin-data namespace. The plugin id is bound when the host is built rather than passed as an argument, so one plugin can never name another's namespace. openPath refuses executable file types, checked on both the path passed and its realpath target so a benignly-named symlink cannot become a launch primitive.
Settings and storage
Two persistence surfaces with different audiences. settings holds user-facing values declared in contributes.settings and rendered as a generated form. storage is the machine-owned counterpart: a plugin's own working state, not surfaced in the settings UI and not requiring a manifest declaration.
const token = await host.settings.get<string>("linear.apiToken");
await host.settings.set("linear.defaultTeam", "engineering");
const dispose = await host.settings.onDidChange("linear.apiToken", reconnect);
await host.storage.set("lastSyncCursor", cursor); // "user" scope by default
await host.storage.set("draft", text, "worktree"); // tracks the active worktree
const cursor = await host.storage.get<string>("lastSyncCursor"); settings defaults to "user" scope; "project" resolves the active project at call time, so it tracks project switches. storage adds a third "worktree" scope and keeps reads coherent across a switch by invalidating the cache itself. Both reject undefined and non-serializable values, and both onDidChange methods fire only on in-process writes.
storage is plaintext JSON with no encryption. Never put credentials in it. Use a type: "secret" setting instead, which is encrypted through the OS keychain where one exists. Trust & Capabilities covers the fallback behavior when it does not.logger writes to a bounded per-plugin ring buffer (roughly the last 500 entries) and mirrors to the host console prefixed with the plugin id. Calls return void and never throw: an unserializable payload is coerced to a string. Every line is secret-scrubbed before it reaches either sink.
The view component contract
A panel's React component is the default export of the module named by contributes.views[].componentPath. The host lazy-imports it over the plugin:// protocol and mounts it under an error boundary and a suspense boundary.
// src/dashboard.tsx
import { useEffect, useState } from "react";
import type { PanelViewProps } from "@daintreehq/plugin-sdk";
import { usePluginEvent } from "@daintreehq/plugin-sdk/react";
export default function Dashboard({ panelId, pluginId, disposeSignal }: PanelViewProps) {
const [worktreeName, setWorktreeName] = useState<string | null>(null);
usePluginEvent<{ name: string }>(pluginId, "worktree", (wt) => setWorktreeName(wt.name));
useEffect(() => {
const controller = new AbortController();
const onAbort = () => controller.abort();
disposeSignal.addEventListener("abort", onAbort);
void fetch(`plugin://${pluginId}/api/cost-summary`, { signal: controller.signal });
return () => {
disposeSignal.removeEventListener("abort", onAbort);
controller.abort();
};
}, [pluginId, disposeSignal]);
return <div data-panel-id={panelId}>Dashboard for {worktreeName ?? "no worktree"}</div>;
} | Prop | Type | What it is |
|---|---|---|
panelId | string | Runtime id of this panel instance. The routing key for per-instance pushes, and a good key for panel-scoped local state. |
pluginId | string | The plugin's manifest name. Stable for the host's lifetime. Use it to namespace storage keys and log lines. |
disposeSignal | AbortSignal | Lifetime of this mounted view attempt. Aborts on unmount, on "Try again", and when the panel kind disappears. A temporary unmount aborts it too. |
panelRemovedSignal | AbortSignal | Lifetime of the panel record. The same object is handed to every mount of a given panelId, so it survives remounts, retries, trash-then-restore, and plugin upgrades. Aborts exactly once, on permanent removal. |
initialArgs | record, optional | The argument bag the panel was spawned with, set when it was opened through panel.openPluginPanel with initialArgs, for example from a context menu carrying a file path. It rides the panel's persisted state, so a restored panel sees the args it was originally spawned with. |
worktreeId | string, optional | The worktree the panel instance belongs to, recorded at spawn time. Lets a view reconstruct its own context rather than asking for the visible worktree, which is the wrong answer for a background or restored panel. |
Which signal to use
A panel outlives its views, and conflating the two is the single most common plugin bug.
- View-scoped work (in-flight fetches, DOM observers, panel subscriptions, UI timers) belongs on
disposeSignal. - Panel-scoped work (anything that should survive being backgrounded but not survive the panel) belongs on
panelRemovedSignal. - Durable resources (spawned processes, long-lived sessions, anything expensive to restart) belong in the worker, released from
onDidChangePanelLifecycleon"removed". The worker observes the panel across every remount; the view cannot, because it is gone during exactly the teardown that matters.
The error boundary renders a diagnostics pane with Try again, Close panel, Copy diagnostics, and View logs. "Try again" mints a fresh lazy reference so the dynamic import is genuinely re-evaluated rather than returning the cached failed promise.
React hooks and the raw bridge
The @daintreehq/plugin-sdk/react subpath carries the renderer hooks. It is a separate import path so a plugin's main does not pull React into the main-process bundle.
useHostChannel(pluginId, channel): the pull half. Binds a single-flightinvoke(args)to the plugin'sregisterHandler. It resolves with the validated result, orundefinedwhen the host rejected the call, surfacing the rejection onerrorrather than throwing.loadingreflects only the latest call; a secondinvokedrops the stale earlier one.usePluginEvent(pluginId, channel, handler): the push half, receiving broadcasts. The handler is kept ref-stable, so an inline closure does not re-subscribe on every render.usePluginPanelEvent(pluginId, channel, panelId, handler): the same, narrowed to pushes targeted at one panel instance.
These hooks resolve only in a bundled view. The @daintreehq/plugin-vite preset bundles the SDK into the plugin's output, so the hooks ship inside the bundle. The host import map serves React specifiers and nothing else, so a raw, un-bundled plugin:// view that bare-imports the react subpath fails at runtime with an unresolved specifier. Hand-authored views use the bridge the hooks wrap.
// Push half: mirrors usePluginEvent. Returns an unsubscribe function.
const off = window.electron.plugin.on(pluginId, "build-status", (status) => {
setBuildStatus(status);
});
// Pull half: mirrors useHostChannel's invoke().
const result = await window.electron.plugin.invoke(pluginId, "sync-now", { team: "engineering" }); Module generations
Chromium caches ESM module records by URL and offers no eviction API, so re-importing the same plugin:// specifier returns the module already in memory no matter how thoroughly a panel remounts. Daintree closes that gap by stamping a per-load generation segment into the view URL.
plugin://acme.cost-dashboard/__dtv-7/dist/dashboard.js Every time a plugin is loaded (install, replacement install, enable, or app start) a new generation is minted. That is a specifier the renderer has never imported, so the new bundle is genuinely fetched and evaluated, and open panels remount onto it automatically. No force reload, and no hand-versioned bundle filenames.
Two consequences. Relative imports inside the entry module inherit the generation, so a multi-chunk bundle refreshes as a unit, but an absolute plugin:// import written by hand does not, and keeps resolving to the first version imported in that session. And the hot-reload dev loop is the deliberate exception: daintree-plugin dev respawns the plugin's worker without re-registering contributions, so backend changes take effect while open views keep the module they already have. Reopen the plugin, or force-reload the window, to pick up view changes mid-session.
The generation segment is virtual: it never exists on disk, and the protocol handler strips it before resolving the file. Treat that prefix as a reserved top-level directory name.
What the host does not expose
- Other plugins' state or handlers. No cross-plugin reach.
- The user's AI provider keys. A plugin that needs AI calls ships its own
secretsetting. - Full control of a running agent. Driving, pausing, or reading back an agent session stays gated.
sendToActiveAgentis the one sanctioned exception; for everything else, dispatch into an existing action. - An inbound webhook or host-side HTTP listener. Deferred.
- Daintree's internal event bus. Only the specific subscriptions above are exposed, so internal shape changes stay free to happen.
- Raw Electron main-process APIs. The contained equivalents (
process,fs,git,clipboard,system) are the audited path. A plugin can still import Node modules directly; the host does not intercept that, and is honest about it in Trust & Capabilities.
registerForgeProvider is a no-op for every user-installed plugin. A forge provider's parseRemote and URL builders are synchronous and cannot cross the worker's async MessagePort, so only Daintree's built-in plugins, which activate in-process, can register one. Packaging and installing does not restore it; it is an architectural gap, not a dev-mode limitation.Testing against a mock host
createMockHost returns a PluginHostApi that mirrors production validation and capability gating, so a unit test can run activate() and assert what it called. It validates the same things the real host does (toast bounds, badge shape, channel format, quick-pick item arrays), so a malformed call fails the test the way it would fail in the app. Recording arrays expose every host call in order, and passing a narrower capabilities list lets you assert the PERMISSION_REQUIRED rejection a missing capability produces. See Building & Distributing for where it currently lives.