Plugin manifest
gpty-plugin.toml - the schema lives in crates/gpty-cli/src/plugin_manifest.rs (parse_manifest). Top-level keys: id (owner/name, [a-z0-9-] parts 1–63 chars), name (1–64 chars), version + min_gpty_version (semver X.Y.Z, no pre-release), platforms (subset of linux|macos|windows, default all), build/startup (argv arrays), and five array-of-table sections: [[actions]] (≤64: name, command, optional args), [[events]] (≤64: name, type), [[link_handlers]] (≤16: scheme, command argv), [[concepts]] (≤128), [[profiles]] (≤64, ≤64 tiles each). The first-party tool-layout plugins are one repo per tool under the godot-pty org - gpty-omp, gpty-herdr, gpty-lazygit, gpty-neovim, gpty-claude - each a single manifest with that tool’s [[profiles]]; a repo is the install/review/consent unit (see the profiles-migration item).
Install & lifecycle (same release): gpty plugin install <owner>/<repo>[@ref] - @ref accepts any advertised git ref (tag, branch, or commit SHA); a bare install means the default branch’s latest, and the review dialog shows the requested ref beside the resolved revision while the store pins the SHA (a moved tag = new revision = re-review) - clones https://github.com/<owner>/<repo> into a staging dir under the data dir (data_dir()/staging, git driven by argv, never a shell), parses the manifest (64 KiB cap), gates it (the manifest’s id must equal the install target; the current platform must be declared; min_gpty_version must not exceed the CLI), and - because a plugin’s actions run as the user with the workspace API - sends a summary over the pluginInstall IPC method for a human to review in the GUI (workspace.gd:_show_plugin_review, the Workspace Trust pattern with the paneWait deferred response; the Rust fallback deadline for this method is 300 s, not 5 s, and that deadline travels with the request - drain_ipc_requests hands GDScript the remaining milliseconds, so the review dialog closes itself when the request dies and a late click cannot answer a dead channel while the user believes the install happened). No GUI answers (declined, disconnected, --no-daemon) => nothing installs. On acceptance the content moves to data_dir()/plugins/<id> (rename on one filesystem, old revision swapped with rollback), the per-plugin runtime dirs state_dir()/plugins/<id>/{config,state,logs} are created, and the record lands in the store: state_dir()/plugins.json (crates/gpty-cli/src/plugin_store.rs - {id, revision, enabled, installed_at}, ≤256 records, atomic random-suffix temp + rename 0600, strict on corrupt files). list / enable / disable / uninstall / logs manage the store and dirs; run <id> <action> spawns GPTY_BIN_PATH (or the current exe) with the action’s command + --key value args - argv only, output captured to the log dir and printed on exit. A manifest’s build/startup commands are declared, not executed - displayed in the review, run by no current item (executing them is its own trust decision). The plugin subcommand is excluded from the MCP tool list (like state): installing waits on a human, the admin actions touch local state, and pluginInstall is deliberately not a tool; the contract test names it in NON_TOOL_ROUTED.
Rules that must not weaken:
- Nothing executes at parse/validation time, and argv is never a shell string - the same “no shell evaluation” rule as tiles (
build/startup/link-handler commands are argv arrays, ≤32 entries, ≤4096 chars total, no U+FFFD). - Every concept is engine-accepted: the validator round-trips each
[[concepts]]entry through the realgpty_core::concept::concepts_from_json; an entry the engine would silently drop (a trigger/condition outside the Rustregexdialect - e.g. a PCRE2 lookbehind) rejects the manifest. Entries hold to the closed key set the visual editor compiles (name,trigger,enabled,conditions,capture_mode,stop_timeout_ms,stop_on_input,actions;capture_mode∈ {absent,single_line}), so a plugin concept cannot smuggle a key into the engine. - Tiles mirror
PaneTypes.sanitize_tile- pane type ∈ the eightPaneTypes.ALLentries (legacyobserverrefused with guidance), grid geometry withinGRID(60),rows/cols≤ 500/2000,attachment_idpattern - with one deliberate difference: the sanitizer clamps legacy files, the validator rejects (a manifest is authored content; an out-of-range value is an authoring error). Never relax these to “clamp” and never duplicate the grid constants in the GUI. [[actions]].commandis the published API surface - the same derivationschema::mcp_tool_names()produces (the 22 MCP tools); a manifest cannot name anything else.[[events]].typeis the closed event-socket vocabulary (pane.spawned,pane.killed,concept.matched,state.declared, plus the generic OMP setsession.bound,agent.started,turn.started,tool.call,thinking.delta). Adding a vocabulary entry means adding it to those constants deliberately, in lockstep with the emitting side.- Unknown keys are rejected in manifest sections (closed schema); unknown tile settings keys are tolerated, mirroring
_set_typed’s inertness. - Install is the one writer of the GUI’s plugin view. At acceptance the CLI stores the validated manifest’s profiles in the record (
{id, revision, enabled, installed_at, profiles: [{name, tiles}]}- caps 64 profiles × 64 tiles, a strict reader that rejects malformed shapes, and an absentprofileskey reading as empty so records written before this stay valid), andGptyTerminal.installed_plugin_profiles()returns the enabled records’ profiles as JSON forProfileManager(see the Profiles bullet). The GUI never parses TOML: the validator stays the single gate, and a hand-broken store degrades one entry at a time (the FFI logs the reason and answers[], the GUI’s refresh skips malformed entries). The same record carries the manifest’s concepts (concepts: [...], cap 128 entries, the closedCONCEPT_KEYSset, an absent key reading as empty exactly likeprofiles), andGptyTerminal.installed_plugin_concepts()shapes the enabled records into{plugin_id, revision, name, concept}forConceptManager.refresh_plugin_concepts(). The concept merge puts plugin rules between the shipped defaults and the user’s own - the orderProfileManager.get_all_profilesuses for builtins/plugin/user: names are derived on every merge, a plugin name yielding to a shipped default and to an earlier plugin with(n)(add_profile’s convention, so an uninstall/re-install gives the bare name back), while a user entry naming a rule is that rule’s overlay (a toggle writes a thin{name, enabled}, an edit a full entry) applied by name exactly as on a default - that is what makes the Settings toggle/edit/delete work on installed content. The Settings row carries provenance in a tooltip, and the visual canvas deliberately excludes plugin rules (its no-entry/tombstone save semantics are defined for the shipped defaults and the user’s rules only; plugin rules are managed from Settings). Refreshes fire at init, after an accepted install review (the same deferred re-read as profiles), and on thepluginsChangednotice.[[events]]and[[link_handlers]]are declarations, and the 2026-10 decision is that gpty never dispatches them. Events: core ships the primitives - the event socket, open to same-UID subscribers, whose path a plugin program launched in a pane already receives asGPTY_EVENT_SOCKET- while an event→plugin-action dispatcher is out of core by the ADE boundary (observe/display, never orchestrate) and would be a new execution path driven by signals terminal output or a Tier-2 declaration can provoke; a plugin that wants events reads the socket itself. Link handlers: the Markdown view opens onlyhttp/https/mailtothroughOS.shell_openafter a confirmation and ignores any other scheme, so nothing consults these entries - a click path would need an env-stripped argv spawn surface (Godot’s own process spawn cannot dropGPTY_SECRETor a pane’s event capability), a reserved-scheme policy and per-click consent, and no consumer exists. Both sections stay in the accepted vocabulary as reviewed declarations (the review text says “declared”;build/startupsay “not run by gpty”), and pruning them would be a deliberate schema-break decision, never an oversight - revisit only with an ecosystem consumer plus that trust design. The store’s other writer is announced, not polled:gpty plugin uninstall|enable|disablefirespluginsChanged {"action", "id"}over the control socket after the record is written, and the GUI’sipc_handlers.gdarm answers it by re-reading the store synchronously - so the CLI’s reply means the profile list has caught up (the install review is the opposite case and keeps its deferred re-read, because the clone lands only after the answer). The notice is best-effort in the one direction that matters: no GUI, a refused connect or a timeout is dropped, and an admin action never starts a GUI (daemon::ensure_runningis deliberately absent here, unlikeinstall). It is routed but never an MCP tool (NON_TOOL_ROUTED), likepluginInstall: an MCP client has no reason to poke a GUI’s profile list. Both fields are validated even though onlyactionhas a consumer today -idis inert (nothing stores or looks anything up with it) and the CLI cannot deliver a malformed one, since it notifies only afterPluginStoreaccepted the id; it is checked because validation is a boundary property rather than a property of the fields that happen to be read, and the shape is not re-declared: the GUI’s one mirror isTrustedStore.valid_plugin_id(public precisely because it now has two consumers - the consent records keyed on the same id, and this notice), withplugin_manifest.rs::valid_plugin_idstill authoritative. gpty plugin validate <path>runs the shipped parser on a plugin directory (or itsgpty-plugin.toml): offline,--json-capable, exit 1 on rejection - the authoring loop, and what the five first-party plugin repos’ CI now runs: each installs the CLI from the v0.5.5 commit (cargo install --git … --rev 2f17459… --locked; the SHA is the pin, a tag is mutable) and validates its checkout with it. Each repo also keeps its own one-lineid-equals-repo assertion, because that is the install gate’s contract (check_gates) - the validator cannot know which repo a manifest lives in.- The install review shows what a profile would run: each summary
profilesentry carriesprograms(the distinctsettings.commandvalues its tiles name, capped) and the dialog prints them, so accepting a plugin is informed consent for the profiles_profile_consentedlater honors at the revision the install pinned. - Registry (2026-09):
godot-pty/gpty-pluginsis the canonical index -registry.jsonplusvalidate.py- and the docs site’s/plugins/page renders it: fetched live at build time, with the snapshot committed atdocs/assets/registry.jsonas the offline fallback (the shortcode warns in the build log when it falls back, so a silently-stale page is visible to a maintainer;docs/data/is generated bysetup.sh, which is why the snapshot is an asset). An entry is a closed key set -id(owner/repo),repo,name,description,category,min_gpty_version,platforms- validated structurally and, with--check-repos, against every listed repo’sgpty-plugin.tomlat HEAD (the repo’s workflow runs that on push, on PRs, and weekly, so a vanished or renamed repo is noticed). Policies live in that repo’s README and must not weaken: an id is owned by its repo and immutable while the repo exists;min_gpty_versionis a floor the registry states, never an install gate (the CLI’s own check is the gate); removing an entry stops new installs only, because every install is pinned to a commit SHA. The registry is display data - nothing in it executes, andgpty plugin installnever consults it.