Plugins

A plugin is one Clojure file that adds editor commands and keys, hooks on saves, Runs and errors, and tools an agent can call. You can write one, or an agent can propose one that waits for your Accept.

Safety. A plugin runs with Jonquil's own rights, so treat its file like any code you run. :fs, :proc and :net are declarations only, and no effect is a sandbox: a plugin can still call Java, read files or start processes. Effects decide which of Jonquil's functions the plugin gets and what the review shows you. A file you put in the folder yourself gets no review. Accept on an agent's proposal runs that code in Jonquil.

Files live in plugins/ beside the settings file (~/.config/jonquil/plugins/ or $XDG_CONFIG_HOME/jonquil/plugins/). A file is <id>.clj (id: lower-case letters, digits and -, starting with a letter), its namespace is jonquil.plugins.<id>, and it calls defplugin once:

(ns jonquil.plugins.wrap-when
  (:require [jonquil.plugin :as p]))

(p/defplugin
  {:id      :wrap-when
   :doc     "Wrap the form at the caret in (when ...)."
   :effects #{}
   :commands
   [{:id :wrap-when/wrap :doc "Wrap the form at the caret in (when ...)."
     :scope :editor
     :edit (fn [snapshot] (p/wrap-form snapshot "(when " ")"))}]
   :bindings
   [{:key "j" :mods #{:cmd :alt} :command :wrap-when/wrap}]})

An :edit command gets {:text :caret :sel-start :sel-end} and returns {:edits [[start end replacement] ...] :caret n} (edits in ascending order), or nil to let the key go on as usual. p/form-at-caret and p/wrap-form help build edits.

Commands and effects

A command that does more than edit is a :run command, (fn [ctx] ...), and the plugin lists what it reaches in :effects:

(ns jonquil.plugins.shout
  (:require [clojure.string :as str]
            [jonquil.plugin :as p]))

(p/defplugin
  {:id      :shout
   :doc     "Upper-case the whole buffer."
   :effects #{:buffer :console}
   :commands
   [{:id :shout/go :doc "Upper-case the whole buffer." :scope :window
     :run (fn [ctx]
            (p/replace-text! ctx (str/upper-case (p/buffer-text ctx)))
            (p/status! ctx "Shouted."))}]
   :bindings
   [{:key "u" :mods #{:cmd :alt} :command :shout/go}]})

A :run command runs in the background on the tab that was active when it started, and its key always counts as used. One run goes at a time, and nothing stops one that never finishes short of quitting. A throw shows on the status bar and console with the plugin's id.

EffectFunctions
:console(p/print! ctx s) console line, (p/status! ctx s) status bar
:buffer(p/buffer-text ctx), (p/replace-text! ctx text) (one undo step; text holding a NUL byte throws, naming the line), (p/open! ctx path) opens a tab and returns its id; (assoc ctx ::p/tab id) acts on it
:eval(p/eval! ctx code) quiet, returns {:value :out :err}; (p/repl! ctx code) at the console, returns {:value :out}. Both take {:timeout-ms n} as a third argument

Calling a function whose effect wasn't declared throws "this plugin didn't declare :buffer"; a closed tab throws "that tab was closed".

Both eval functions run in the tab's namespace, wait 10 seconds by default ("the eval took longer than 10000 ms and was stopped"), and throw "the REPL is busy" if something else is running.

  • p/eval! leaves the console, history and *1/*2/*3/*e alone. A throw comes back as :error. Jolt dialect only; other dialects get told to use p/repl!.
  • p/repl! works like Run Selection: echoed, shown, added to history. It works everywhere.

Stopping an eval equals pressing Stop. For Jolt as a child (a project with a deps.edn), code still running a second after the interrupt makes Jonquil restart the child, and its definitions are lost.

Hooks

:hooks maps a hook point to (fn [ctx event] ...). The ctx reaches only the plugin's declared effects.

HookEvent
:before-run{:kind :text :path} before a Run sends :text. Return {:abort "sentence"} to stop it
:after-save{:path p} after a successful save
:after-run{:kind :status :value :error}; :kind is :run, :selection or :form; :status is :ok, :error, :interrupted or :notice (dialect couldn't run it)
:on-error{:source :text :line :column}; :source is :run, :selection, :form or :repl. :line/:column only when the error text names them (in-process Jolt, compiler errors only)

A Run is Run, Run Selection or Run Form, from a key, menu, palette or an agent's jonquil_run/jonquil_run_selection. A Run that never started (REPL busy, other project's tab, unclosed bracket) fires nothing. :on-error also hears console errors, but never those from a plugin's own p/eval!/p/repl!.

Hooks other than :before-run run in the background after the fact and can't change the save or Run. At one point they run in plugin-id order, each getting the previous one's returned event (non-maps are ignored). A throw shows with the plugin's id and the next hook still runs. Up to 16 events queue while a point is busy; further ones are skipped with a console line.

:before-run is synchronous, because the Run waits for it:

(ns jonquil.plugins.no-todo
  (:require [jonquil.plugin :as p]))

(p/defplugin
  {:id      :no-todo
   :doc     "Refuse to Run code that still says TODO."
   :effects #{}
   :hooks   {:before-run (fn [_ e]
                           (if (.contains ^String (:text e) "TODO")
                             {:abort "the code still has a TODO"}
                             e))}})

A stopped Run shows ; plugin no-todo stopped this Run: the code still has a TODO on the console and status bar, sends nothing, and gives an agent's jonquil_run the same line as its error. The first abort wins. Editing :text changes nothing, and a non-sentence :abort is reported and ignored.

  • All :before-run hooks together get 100 ms. If they time out or throw, the Run goes ahead with a line saying so. While a hook from an earlier Run hasn't returned, every later Run skips all :before-run hooks, with a line saying so.
  • :before-run hooks can't use effects, even declared ones (p/print! etc. throw and the Run goes ahead).
  • Inspect Form, the console and jonquil_eval don't ask these hooks.

Loading and reloading

Plugins load at startup, one file at a time. A broken file is skipped and the console names the problem (with a line when known). A file gets 10 seconds to load. Each :edit command is tried on sample buffers (2 seconds each) before going live, and its answer is checked on your real buffer every time it runs, so a wrong answer is refused instead of editing the wrong text.

Bindings must hold Cmd/Ctrl. Chords the window takes first are refused (Cmd+Alt+W is the window's Cmd+W). A binding that loses only some chords loads with a warning (:mods #{:cmd} on P also matches Cmd+Shift+P, the palette's), and so does a chord a built-in already uses. Plugins take keys in id order, so on a shared chord the earlier id wins and the later one gets a warning. Plugin bindings come ahead of built-in ones; their commands appear in the palette with the plugin's id.

Reload. Saving a plugin file in a tab reloads it. Reload Plugins in the palette reloads every file that changed, appeared or went away (needed for edits made elsewhere). A failed reload keeps the previous version running and your file untouched. Later plugins are rechecked, and one that now fails is removed and named until fixed.

No plugins. --no-plugins (or JONQUIL_NO_PLUGINS=1 for a Dock or Finder launch) starts with none. Reload, Enable, Disable, Revert and Accept refuse with a line saying plugins are off, and an agent can't propose one. The list, History and review still open. To keep one off persistently, add its id to settings.edn's :plugins :disabled and start normally.

The plugin list

The palette's Show the plugins entry lists every plugin in the right-hand panel: id, live version, loaded/disabled/not loaded, effects, pending proposal and doc (the same as jonquil_plugins_list). A plugin that fails its checks shows as not loaded, and one whose file was deleted keeps its row ("file deleted"). Select a row, then:

  • Disable: writes the id into settings.edn and reloads; commands and keys go at once. The file stays.
  • Enable: removes its id from :disabled and reloads through the usual checks.
  • Open: the file in a tab.
  • History: its versions.
  • Review Proposals: pending agent proposals.

Disable writes this and keeps your other settings (you can also write it by hand, then Reload Plugins). If :plugins holds something Jonquil can't use, Enable and Disable refuse rather than overwrite it:

{:plugins {:disabled #{:wrap-when}}}

History and revert

Every version that goes live is kept in plugins/.history/<id>/ (1.clj, 2.clj, ... plus history.edn with when, where from and why). Entries are never changed or removed. A new version is added when a plugin loads with text different from its latest, with the reason "edited by hand"; an edit that fails its checks isn't one. An unreadable history.edn is left alone and the plugin still loads.

History lists versions newest first (status, source: you, an agent or a revert, when, why). Select an older one, add an optional reason, press Revert. It runs the reload checks, writes the file, and records a new version ("revert to v2"), so nothing is erased. Back returns to the list.

Revert refuses, saying why in the footer, if the version was never live (proposals go through Accept), the plugin is disabled, the text is already live, or the file differs from the live version (an unreloaded or failing edit a write would lose). A tab open on the file follows a Revert or Accept: it reloads if clean, and otherwise keeps your unsaved edits with a one-time console warning that saving overwrites the version on disk. The reload is one undo step, as for any file changed on disk.

Proposals from an agent

An agent proposes a change with jonquil_plugin_propose (see Driving Jonquil from an agent (MCP)). It becomes a pending version and the status bar counts the waiting ones. Review Plugin Proposals in the palette shows, in the right-hand panel: the agent's reason, whether it builds on the live version or an older one, a diff, the declared effects (new ones marked), and the checks' warnings and timings. Next steps through several.

Accept reruns the checks in Jonquil, then writes the file and makes it live. If they fail, it stays pending and the footer says why. Reject takes an optional note. The agent can read the outcome but can never Accept.

The isolated check. Before review, the proposal is checked in a separate Jolt, in a fresh temporary folder only you can open, with credential-looking environment variables removed. This is isolation from the running Jonquil, not a sandbox. The child runs as you, with your files and network, so top-level code that deletes a file deletes it during the check (no more reach than an agent has via jonquil_eval), and the scrub catches only obvious names. It could even rewrite a file Jonquil reads later, such as another plugin or settings.edn. What it can't touch before Accept is the running Jonquil: your tabs, REPL, loaded plugins and already-read settings, and it can't crash or hang the window.

  • A check over 30 seconds is stopped and the agent gets that reason.
  • After a timeout, no result or an error exit, the check's folder is kept for a day; the agent's answer and the History line give the path of check.log.
  • With no jolt available, the proposal waits unchecked (the review says so) and its checks run on Accept.

This page is docs/guide/plugins.md in the Jonquil repository.

Enter to open · Esc to close