#[cfg(all(test, feature = "plugin"))]
#[path = "mod_tests.rs"]
mod tests;
use std::collections::HashMap;
use worker::Worker;
pub use worker::{DialogReply, DialogRequest, LspRequest};
#[cfg(feature = "plugin")]
pub mod extension;
pub mod hook;
pub mod loader;
pub mod worker;
#[cfg(feature = "plugin")]
pub fn spawn_headless_dialog_responder(
mut dialog_rx: tokio::sync::mpsc::UnboundedReceiver<DialogRequest>,
mode: crate::cli::AutoConfirmMode,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(req) = dialog_rx.recv().await {
match req {
DialogRequest::Confirm { reply, .. } => {
let answer = matches!(mode, crate::cli::AutoConfirmMode::Yes);
let _ = reply.send(DialogReply::Confirm(answer));
}
DialogRequest::Select { options, reply, .. } => {
let picked = match mode {
crate::cli::AutoConfirmMode::Yes => options.into_iter().next(),
crate::cli::AutoConfirmMode::No => None,
};
let _ = reply.send(DialogReply::Select(picked));
}
}
}
})
}
#[cfg(all(feature = "plugin", feature = "lsp"))]
pub fn spawn_lsp_responder(
mut lsp_rx: tokio::sync::mpsc::UnboundedReceiver<LspRequest>,
manager: std::sync::Arc<crate::lsp::manager::LspManager>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(req) = lsp_rx.recv().await {
let json = crate::lsp::harness::run_query(&manager, &req.request).await;
let _ = req.reply.send(json);
}
})
}
#[cfg(all(feature = "plugin", feature = "dap"))]
pub fn spawn_dap_responder() -> tokio::task::JoinHandle<()> {
let (handle, tx) = crate::dap::janet_bindings::spawn_dap_bridge();
crate::dap::janet_bindings::store_dap_tx(tx);
handle
}
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub fn escape_janet_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\x{:02X}", c as u32));
}
c => out.push(c),
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PostDoneAction {
Followup(String),
LoopIter,
LoopStop,
Idle,
}
pub fn decide_post_done_action(
followup: Option<String>,
loop_active: bool,
loop_should_stop: bool,
) -> PostDoneAction {
if let Some(text) = followup {
return PostDoneAction::Followup(text);
}
if !loop_active {
return PostDoneAction::Idle;
}
if loop_should_stop {
PostDoneAction::LoopStop
} else {
PostDoneAction::LoopIter
}
}
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub fn filter_existing_dirs(candidates: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
loader::filter_existing_dirs(candidates)
}
pub use loader::LoadedPlugin;
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub fn load_plugin(
mgr: &mut PluginManager,
path: &std::path::Path,
) -> Result<LoadedPlugin, String> {
loader::load_plugin(mgr, path)
}
pub struct PluginManager {
hooks: HashMap<String, Vec<String>>,
loaded_plugins: Vec<LoadedPlugin>,
worker: Worker,
dialog_rx: Option<tokio::sync::mpsc::UnboundedReceiver<DialogRequest>>,
#[cfg_attr(not(feature = "lsp"), allow(dead_code))]
lsp_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LspRequest>>,
}
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
impl PluginManager {
pub fn try_new() -> Result<Self, String> {
let (worker, dialog_rx, lsp_rx) = Worker::try_spawn()?;
Ok(PluginManager {
hooks: HashMap::new(),
loaded_plugins: Vec::new(),
worker,
dialog_rx: Some(dialog_rx),
lsp_rx: Some(lsp_rx),
})
}
pub fn take_dialog_rx(
&mut self,
) -> Option<tokio::sync::mpsc::UnboundedReceiver<DialogRequest>> {
self.dialog_rx.take()
}
#[cfg_attr(not(feature = "lsp"), allow(dead_code))]
pub fn take_lsp_rx(&mut self) -> Option<tokio::sync::mpsc::UnboundedReceiver<LspRequest>> {
self.lsp_rx.take()
}
pub fn load_file(&mut self, path: &std::path::Path) -> Result<(), String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read plugin: {e}"))?;
self.eval(&content)?;
Ok(())
}
pub fn register(&mut self, hook: &str, script: &str) {
self.hooks
.entry(hook.to_string())
.or_default()
.push(script.to_string());
}
pub fn has_hook(&self, hook: &str) -> bool {
self.hooks.get(hook).is_some_and(|names| !names.is_empty())
}
pub fn take_pending_prompt(&mut self) -> Option<String> {
self.take_string_slot("harness-pending")
}
pub fn store_response(&mut self, response: &str) {
let escaped = escape_janet_string(response);
let _ = self
.worker
.eval(&format!(r#"(set harness-response "{}")"#, escaped));
}
pub fn has_symbol(&mut self, name: &str) -> bool {
let escaped = escape_janet_string(name);
let code = format!(r#"(harness/has-symbol? "{}")"#, escaped);
self.worker
.eval(&code)
.map(|s| s == "true")
.unwrap_or(false)
}
pub fn eval(&mut self, code: &str) -> Result<String, String> {
self.worker.eval(code)
}
pub fn set_loading_plugin_config(&mut self, enabled: bool, auto_start: bool) {
let _ = self.worker.eval(&format!(
"(set harness-plugin-config @{{:enabled {enabled} :auto-start {auto_start}}})"
));
}
pub fn clear_loading_plugin_config(&mut self) {
let _ = self.worker.eval("(set harness-plugin-config nil)");
}
#[cfg(feature = "experimental-ui-computer-use")]
pub fn set_deny_tools_for_computer_use(&mut self, deny: &[String]) {
let items: Vec<String> = deny.iter().map(|t| format!("\"{t}\"")).collect();
let expr = format!(
"(harness/set-computer-use-deny-tools [{}])",
items.join(" ")
);
let _ = self.worker.eval(&expr);
}
pub fn dispatch(&mut self, hook: &str, context_janet: &str) -> Result<Vec<String>, String> {
let names = match self.hooks.get(hook) {
Some(names) => names.clone(),
None => return Ok(Vec::new()),
};
let mut results = Vec::new();
for name in &names {
let hook_escaped = escape_janet_string(hook);
let name_escaped = escape_janet_string(name);
let code = format!(
r#"(try (do (def ctx {ctx}) ({fname} ctx))
([err fib]
(do
(def sanitized
(harness/sanitize-hook-err
(string "[plugin] hook "
"\"{hook_escaped}\""
"."
"\"{name_escaped}\""
" errored: "
err)))
(harness/push-hook-err sanitized)
(string "DIRGE_HOOK_ERR:" err))))"#,
ctx = context_janet,
fname = name,
);
if let Ok(s) = self.eval(&code) {
if let Some(msg) = s.strip_prefix("DIRGE_HOOK_ERR:") {
tracing::warn!(
target: "dirge::plugin",
hook = %hook,
function = %name,
error = %msg,
"plugin hook errored — continuing dispatch",
);
continue;
}
if s != "nil" && !s.is_empty() {
results.push(s);
}
}
}
Ok(results)
}
pub fn take_pending_block(&mut self) -> Option<String> {
self.take_string_slot("harness-block")
}
pub fn take_pending_mutate_input(&mut self) -> Option<String> {
self.take_string_slot("harness-mutate-input")
}
pub fn take_pending_replace_result(&mut self) -> Option<String> {
self.take_string_slot("harness-replace-result")
}
pub fn take_pending_next_model(&mut self) -> Option<String> {
self.take_string_slot("harness-next-model")
}
pub fn take_pending_next_thinking_level(&mut self) -> Option<String> {
self.take_string_slot("harness-next-thinking-level")
}
pub fn take_pending_stop_after_turn(&mut self) -> bool {
let was_set = self
.worker
.eval("(if harness-stop-after-turn true false)")
.map(|s| s == "true")
.unwrap_or(false);
if was_set {
let _ = self.worker.eval("(set harness-stop-after-turn nil)");
}
was_set
}
pub fn drain_steering_messages(&mut self) -> Vec<String> {
self.drain_newline_blob("harness-steering-messages")
}
pub fn drain_followup_messages(&mut self) -> Vec<String> {
self.drain_newline_blob("harness-followup-messages")
}
pub fn drain_custom_messages(&mut self) -> Vec<CustomMessageEntry> {
let raw = self
.worker
.eval("(if (string? harness-custom-messages) harness-custom-messages \"\")")
.unwrap_or_default();
let _ = self.worker.eval(r#"(set harness-custom-messages "")"#);
raw.lines().filter_map(parse_custom_message_line).collect()
}
#[cfg(feature = "experimental-graph-search")]
pub fn drain_entity_records(&mut self) -> Vec<EntityRecord> {
let raw = self
.worker
.eval("(if (string? harness-recorded-entities) harness-recorded-entities \"\")")
.unwrap_or_default();
let _ = self.worker.eval(r#"(set harness-recorded-entities "")"#);
raw.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
Some(EntityRecord {
kind: unescape_harness_field(parts[0]),
name: unescape_harness_field(parts[1]),
extra: if parts.get(2).is_none_or(|s| s.is_empty()) {
None
} else {
Some(unescape_harness_field(parts[2]))
},
})
} else {
None
}
})
.collect()
}
#[cfg(feature = "experimental-graph-search")]
pub fn drain_relation_records(&mut self) -> Vec<RelationRecord> {
let raw = self
.worker
.eval("(if (string? harness-recorded-relations) harness-recorded-relations \"\")")
.unwrap_or_default();
let _ = self.worker.eval(r#"(set harness-recorded-relations "")"#);
raw.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(5, '\t').collect();
if parts.len() == 5
&& !parts[0].is_empty()
&& !parts[1].is_empty()
&& !parts[2].is_empty()
&& !parts[3].is_empty()
&& !parts[4].is_empty()
{
Some(RelationRecord {
source_kind: unescape_harness_field(parts[0]),
source_name: unescape_harness_field(parts[1]),
target_kind: unescape_harness_field(parts[2]),
target_name: unescape_harness_field(parts[3]),
rel_type: unescape_harness_field(parts[4]),
})
} else {
None
}
})
.collect()
}
fn drain_newline_blob(&mut self, var: &str) -> Vec<String> {
let raw = self
.worker
.eval(&format!("(if (string? {var}) {var} \"\")"))
.unwrap_or_default();
let _ = self.worker.eval(&format!("(set {var} \"\")"));
raw.lines()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn take_pending_prompt_replace(&mut self) -> Option<String> {
self.take_string_slot("harness-prompt-replace")
}
pub fn take_system_prompt_append(&mut self) -> Option<String> {
self.take_string_slot("harness-system-prompt-append")
}
pub fn take_message_rewrite(&mut self) -> Option<String> {
self.take_string_slot("harness-message-rewrite")
}
pub fn take_replace_context(&mut self) -> Option<String> {
self.take_string_slot("harness-replace-context")
}
pub fn take_compact_summary(&mut self) -> Option<String> {
self.take_string_slot("harness-compact-summary")
}
fn take_string_slot(&mut self, var: &str) -> Option<String> {
let is_string = self
.worker
.eval(&format!("(if (string? {var}) true false)"))
.map(|s| s == "true")
.unwrap_or(false);
if !is_string {
return None;
}
let val = self.worker.eval(var).ok()?;
let _ = self.worker.eval(&format!("(set {var} nil)"));
Some(val)
}
pub fn dispatch_tool_hook(
&mut self,
hook: &str,
context_janet: &str,
) -> Result<ToolHookResult, String> {
if !self.has_hook(hook) {
return Ok(ToolHookResult::default());
}
const HOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
let _ = self.worker.eval_with_timeout(
"(set harness-block nil) (set harness-mutate-input nil) (set harness-replace-result nil)",
HOOK_TIMEOUT,
);
let names = match self.hooks.get(hook) {
Some(names) => names.clone(),
None => Vec::new(),
};
for name in &names {
let hook_escaped = escape_janet_string(hook);
let name_escaped = escape_janet_string(name);
let code = format!(
r#"(try (do (def ctx {ctx}) ({fname} ctx))
([err fib]
(do
(def sanitized
(harness/sanitize-hook-err
(string "[plugin] hook "
"\"{hook_escaped}\""
"."
"\"{name_escaped}\""
" errored: "
err)))
(harness/push-hook-err sanitized)
(string "DIRGE_HOOK_ERR:" err))))"#,
ctx = context_janet,
fname = name,
);
match self.worker.eval_with_timeout(&code, HOOK_TIMEOUT) {
Ok(s) => {
if let Some(msg) = s.strip_prefix("DIRGE_HOOK_ERR:") {
tracing::warn!(
target: "dirge::plugin",
hook = %hook,
function = %name,
error = %msg,
"plugin hook errored — continuing dispatch",
);
}
}
Err(e) => {
tracing::warn!(
target: "dirge::plugin",
hook = %hook,
function = %name,
error = %e,
"plugin hook timed out or worker disconnected — continuing dispatch without its result",
);
}
}
if self.has_pending_block() {
break;
}
}
Ok(ToolHookResult {
block: self.take_pending_block(),
mutate_input: self.take_pending_mutate_input(),
replace_result: self.take_pending_replace_result(),
})
}
fn has_pending_block(&mut self) -> bool {
match self
.worker
.eval("(if (nil? harness-block) \"\" harness-block)")
{
Ok(s) => !s.is_empty() && s != "nil",
Err(_) => false,
}
}
pub fn push_loaded_plugin(&mut self, plugin: LoadedPlugin) {
self.loaded_plugins.push(plugin);
}
pub fn list_plugins(&self) -> Vec<LoadedPlugin> {
self.loaded_plugins.clone()
}
pub fn list_commands(&mut self) -> Vec<(String, String)> {
let raw = match self.worker.eval("harness-cmd-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<(String, String)> = raw
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let cmd = unescape_harness_field(parts.next()?);
let handler = unescape_harness_field(parts.next()?);
if cmd.is_empty() || handler.is_empty() {
None
} else {
Some((cmd, handler))
}
})
.collect();
dedup_last_wins(parsed, "slash command", |(c, _)| c.clone())
}
pub fn list_keybindings(&mut self) -> Vec<(String, String)> {
let raw = match self.worker.eval("harness-keybindings-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
raw.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let key = unescape_harness_field(parts.next()?);
let command = unescape_harness_field(parts.next()?);
if key.is_empty() || command.is_empty() {
None
} else {
Some((key, command))
}
})
.collect()
}
pub fn invoke_command(
&mut self,
handler_fn: &str,
args: &str,
) -> Result<Option<String>, String> {
let escaped_args = escape_janet_string(args);
let escaped_fn = escape_janet_string(handler_fn);
let handler_fn_escaped = escape_janet_string(handler_fn);
let code = format!(
r#"(try
(let [f (get (curenv) (symbol "{fname}"))]
(if (and f (function? (f :value)))
((f :value) "{args}")
nil))
([err fib]
(do
(def sanitized
(harness/sanitize-hook-err
(string "[plugin] command "
"{handler_fn_escaped}"
" errored: "
err)))
(harness/push-hook-err sanitized)
(string "DIRGE_HOOK_ERR:" err))))"#,
fname = escaped_fn,
args = escaped_args,
);
let result = self.eval(&code)?;
if let Some(msg) = result.strip_prefix("DIRGE_HOOK_ERR:") {
tracing::warn!(
target: "dirge::plugin",
handler = %handler_fn,
error = %msg,
"plugin command/shortcut handler errored — surfaced via notification",
);
return Ok(None);
}
if result == "nil" || result.is_empty() {
Ok(None)
} else {
Ok(Some(result))
}
}
pub fn list_providers(&mut self) -> Vec<(String, String, String, Option<String>)> {
let raw = match self.worker.eval("harness-providers-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<(String, String, String, Option<String>)> = raw
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let name = unescape_harness_field(parts.next()?);
let ptype = unescape_harness_field(parts.next()?);
let base_url = unescape_harness_field(parts.next()?);
let env_raw = unescape_harness_field(parts.next()?);
if name.is_empty() || ptype.is_empty() || base_url.is_empty() {
return None;
}
let env = if env_raw.is_empty() {
None
} else {
Some(env_raw)
};
Some((name, ptype, base_url, env))
})
.collect();
dedup_last_wins(parsed, "plugin provider", |(n, _, _, _)| n.clone())
}
pub fn list_plugin_tools(&mut self) -> Vec<PluginToolMeta> {
let raw = match self.worker.eval("harness-tools-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<PluginToolMeta> = raw.lines().filter_map(parse_plugin_tool_line).collect();
dedup_last_wins(parsed, "plugin tool", |t| t.name.clone())
}
pub fn list_message_renderers(&mut self) -> Vec<(String, String)> {
let raw = match self.worker.eval("harness-msg-renderers-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<(String, String)> = raw
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let t = unescape_harness_field(parts.next()?);
let h = unescape_harness_field(parts.next()?);
if t.is_empty() || h.is_empty() {
None
} else {
Some((t, h))
}
})
.collect();
dedup_last_wins(parsed, "message renderer", |(t, _)| t.clone())
}
pub fn invoke_message_renderer(
&mut self,
handler: &str,
payload_json: &str,
) -> Result<Option<String>, String> {
let escaped_payload = escape_janet_string(payload_json);
let escaped_fn = escape_janet_string(handler);
let code = format!(
r#"(try
(let [f (get (curenv) (symbol "{fname}"))]
(if (and f (function? (f :value)))
(let [r ((f :value) "{payload}")]
(if (string? r) r (string r)))
nil))
([err fib] nil))"#,
fname = escaped_fn,
payload = escaped_payload,
);
let result = self.worker.eval(&code)?;
if result == "nil" || result.is_empty() {
Ok(None)
} else {
Ok(Some(result))
}
}
pub fn list_shortcuts(&mut self) -> Vec<PluginShortcutMeta> {
let raw = match self.worker.eval("harness-shortcuts-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<PluginShortcutMeta> =
raw.lines().filter_map(parse_plugin_shortcut_line).collect();
dedup_last_wins(parsed, "plugin shortcut", |s| s.keys.clone())
}
pub fn invoke_prepare_arguments(
&mut self,
handler: &str,
args_json: &str,
) -> Result<Option<String>, String> {
let escaped_args = escape_janet_string(args_json);
let escaped_fn = escape_janet_string(handler);
let code = format!(
r#"(try
(let [f (get (curenv) (symbol "{fname}"))]
(if (and f (function? (f :value)))
(let [r ((f :value) "{args}")]
(if (string? r) r nil))
nil))
([err fib] nil))"#,
fname = escaped_fn,
args = escaped_args,
);
let result = self.worker.eval(&code)?;
if result == "nil" || result.is_empty() {
Ok(None)
} else {
Ok(Some(result))
}
}
pub fn invoke_plugin_tool(
&mut self,
handler: &str,
args_json: &str,
tool_call_id: &str,
) -> Result<String, String> {
let escaped_args = escape_janet_string(args_json);
let escaped_id = escape_janet_string(tool_call_id);
let code = format!(
r#"(do
(set harness-current-tool-call "{tcid}")
(def result
(try (let [r ({handler} "{args}")]
(if (string? r) r (string r)))
([err fib] (string "DIRGE_TOOL_ERR:" err))))
(set harness-current-tool-call nil)
result)"#,
tcid = escaped_id,
handler = handler,
args = escaped_args,
);
let out = self.worker.eval(&code)?;
if let Some(msg) = out.strip_prefix("DIRGE_TOOL_ERR:") {
Err(msg.to_string())
} else {
Ok(out)
}
}
pub fn drain_tool_progress(&mut self) -> Vec<(String, String)> {
let raw = match self.worker.eval("harness-tool-progress") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let _ = self.worker.eval(r#"(set harness-tool-progress "")"#);
raw.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let id = unescape_harness_field(parts.next()?);
let text = unescape_harness_field(parts.next()?);
if id.is_empty() {
None
} else {
Some((id, text))
}
})
.collect()
}
pub fn drain_notifications(&mut self) -> Vec<(String, String)> {
let _ = self.worker.eval(
r#"(do
(when (and harness-last-hook-err-msg
(> harness-last-hook-err-count 1))
(set harness-notif-list
(string harness-notif-list
"error\t"
harness-last-hook-err-msg
" (repeated "
harness-last-hook-err-count
" times)\n")))
(set harness-last-hook-err-msg nil)
(set harness-last-hook-err-count 0))"#,
);
let raw = match self.worker.eval("harness-notif-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<(String, String)> = raw
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '\t');
let level = parts.next()?.trim();
let msg = parts.next()?;
if level.is_empty() || msg.is_empty() {
None
} else {
Some((level.to_string(), msg.to_string()))
}
})
.collect();
let _ = self.worker.eval(r#"(set harness-notif-list "")"#);
parsed
}
pub fn drain_entries(&mut self) -> Vec<(String, String, bool)> {
let raw = match self.worker.eval("harness-entries-buf") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<(String, String, bool)> = raw
.lines()
.filter_map(|line| {
let mut parts = line.splitn(3, '\t');
let custom_type = unescape_harness_field(parts.next()?);
let data = unescape_harness_field(parts.next()?);
let display = parts.next().is_some_and(|d| d.trim() == "1");
if custom_type.is_empty() {
None
} else {
Some((custom_type, data, display))
}
})
.collect();
let _ = self.worker.eval(r#"(set harness-entries-buf "")"#);
parsed
}
pub fn list_renderers(&mut self) -> Vec<(String, String)> {
let raw = match self.worker.eval("harness-renderer-list") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
raw.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '|');
let kind = parts.next()?.trim();
let handler = parts.next()?.trim();
if kind.is_empty() || handler.is_empty() {
None
} else {
Some((kind.to_string(), handler.to_string()))
}
})
.collect()
}
pub fn invoke_renderer(
&mut self,
handler_fn: &str,
data: &str,
) -> Result<Vec<(String, String)>, String> {
let _ = self.worker.eval(r#"(set harness-render-buf "")"#);
let escaped_data = escape_janet_string(data);
let escaped_fn = escape_janet_string(handler_fn);
let code = format!(
r#"(try
(let [f (get (curenv) (symbol "{fname}"))]
(if (and f (function? (f :value)))
((f :value) "{data}")
nil))
([err fib] nil))"#,
fname = escaped_fn,
data = escaped_data,
);
let _ = self.eval(&code)?;
let raw = self.worker.eval("harness-render-buf").unwrap_or_default();
if raw.is_empty() {
return Ok(Vec::new());
}
let parsed: Vec<(String, String)> = raw
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '\t');
let color = parts.next()?.trim();
let text = unescape_harness_field(parts.next()?);
if color.is_empty() {
None
} else {
Some((color.to_string(), text))
}
})
.collect();
let _ = self.worker.eval(r#"(set harness-render-buf "")"#);
Ok(parsed)
}
pub fn drain_tree_ops(&mut self) -> Vec<TreeOp> {
let raw = match self.worker.eval("harness-tree-ops") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
if raw.is_empty() {
return Vec::new();
}
let parsed: Vec<TreeOp> = raw.lines().filter_map(parse_tree_op_line).collect();
let _ = self.worker.eval(r#"(set harness-tree-ops "")"#);
parsed
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub struct PluginToolMeta {
pub name: String,
pub description: String,
pub label: String,
pub parameters: String,
pub handler: String,
pub execution_mode: Option<String>,
pub prepare_handler: Option<String>,
}
fn parse_plugin_tool_line(line: &str) -> Option<PluginToolMeta> {
let mut parts = line.split('\t');
let name = unescape_harness_field(parts.next()?);
let description = unescape_harness_field(parts.next()?);
let label = unescape_harness_field(parts.next()?);
let parameters = unescape_harness_field(parts.next()?);
let handler = unescape_harness_field(parts.next()?);
let mode_raw = parts.next().unwrap_or("").trim();
let prepare_raw = parts.next().map(unescape_harness_field).unwrap_or_default();
if name.is_empty() || handler.is_empty() {
return None;
}
if !is_valid_tool_name(&name) {
tracing::warn!(
target: "dirge::plugin",
tool = %name,
"plugin tool name contains chars outside [a-zA-Z0-9_-]; dropping",
);
return None;
}
let execution_mode = match mode_raw {
"sequential" | "parallel" => Some(mode_raw.to_string()),
_ => None,
};
let prepare_handler = if prepare_raw.is_empty() {
None
} else {
Some(prepare_raw)
};
Some(PluginToolMeta {
name,
description,
label,
parameters,
handler,
execution_mode,
prepare_handler,
})
}
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
fn is_valid_tool_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub struct CustomMessageEntry {
pub custom_type: String,
pub content: String,
pub display: bool,
}
#[cfg(feature = "experimental-graph-search")]
#[derive(Debug, Clone)]
pub struct EntityRecord {
pub kind: String,
pub name: String,
pub extra: Option<String>,
}
#[cfg(feature = "experimental-graph-search")]
#[derive(Debug, Clone)]
pub struct RelationRecord {
pub source_kind: String,
pub source_name: String,
pub target_kind: String,
pub target_name: String,
pub rel_type: String,
}
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
fn dedup_last_wins<T, K, F>(entries: Vec<T>, kind: &str, key_of: F) -> Vec<T>
where
T: Clone,
K: Eq + std::hash::Hash + std::fmt::Display + Clone,
F: Fn(&T) -> K,
{
use std::collections::HashMap;
let mut last_index: HashMap<K, usize> = HashMap::new();
for (i, e) in entries.iter().enumerate() {
last_index.insert(key_of(e), i);
}
let mut out = Vec::with_capacity(last_index.len());
let mut seen_drops: HashMap<K, usize> = HashMap::new();
for (i, e) in entries.iter().enumerate() {
let k = key_of(e);
let last = *last_index.get(&k).expect("populated above");
if i == last {
out.push(e);
} else {
*seen_drops.entry(k.clone()).or_insert(0) += 1;
}
}
for (k, dropped) in seen_drops {
tracing::warn!(
target: "dirge::plugin",
kind = %kind,
key = %k,
dropped = dropped,
"duplicate plugin registration — keeping last-load-wins entry",
);
}
out.into_iter().cloned().collect()
}
fn parse_custom_message_line(line: &str) -> Option<CustomMessageEntry> {
let mut parts = line.split('\t');
let custom_type = unescape_harness_field(parts.next()?);
let content = unescape_harness_field(parts.next()?);
let display_raw = parts.next().unwrap_or("1").trim();
if custom_type.is_empty() && content.is_empty() {
return None;
}
Some(CustomMessageEntry {
custom_type,
content,
display: display_raw != "0",
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub struct PluginShortcutMeta {
pub keys: String,
pub handler: String,
pub description: String,
}
fn parse_plugin_shortcut_line(line: &str) -> Option<PluginShortcutMeta> {
let mut parts = line.split('\t');
let keys = unescape_harness_field(parts.next()?);
let handler = unescape_harness_field(parts.next()?);
let description = unescape_harness_field(parts.next().unwrap_or(""));
if keys.is_empty() || handler.is_empty() {
return None;
}
Some(PluginShortcutMeta {
keys,
handler,
description,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub enum TreeOp {
SetLabel { id: String, label: Option<String> },
Fork { id: String, restore_text: bool },
NavigateTree { id: String },
NewSession { parent: Option<String> },
SwitchSession { id_prefix: String },
}
fn parse_tree_op_line(line: &str) -> Option<TreeOp> {
let mut parts = line.split('\t');
let op = parts.next()?.trim();
if op.is_empty() {
return None;
}
let arg1 = parts.next().map(unescape_harness_field).unwrap_or_default();
let arg2 = parts.next().map(unescape_harness_field).unwrap_or_default();
match op {
"set-label" => {
if arg1.is_empty() {
None
} else {
Some(TreeOp::SetLabel {
id: arg1,
label: if arg2.is_empty() { None } else { Some(arg2) },
})
}
}
"fork" => {
if arg1.is_empty() {
None
} else {
Some(TreeOp::Fork {
id: arg1,
restore_text: arg2 != "at",
})
}
}
"navigate-tree" => {
if arg1.is_empty() {
None
} else {
Some(TreeOp::NavigateTree { id: arg1 })
}
}
"new-session" => Some(TreeOp::NewSession {
parent: if arg1.is_empty() { None } else { Some(arg1) },
}),
"switch-session" => {
if arg1.is_empty() {
None
} else {
Some(TreeOp::SwitchSession { id_prefix: arg1 })
}
}
other => {
tracing::warn!(target: "dirge::plugin", op = other, "drain_tree_ops: unknown op verb (skipped)");
None
}
}
}
fn unescape_harness_field(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('\\') => out.push('\\'),
Some('t') => out.push('\t'),
Some('n') => out.push('\n'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolHookResult {
pub block: Option<String>,
pub mutate_input: Option<String>,
pub replace_result: Option<String>,
}