use crate::client::{spawn_daemon_detached, Client};
use autofork_core::config::Paths;
use autofork_core::protocol::{Event, EventKind, RequestBody, ResponseBody};
use serde::Deserialize;
use std::path::PathBuf;
use std::time::Duration;
const CLIENT: &str = "opencode";
const PLUGIN_FILE: &str = "autofork.js";
const FEED_DRAIN_WAIT_MS: u64 = 4000;
const PLUGIN_SOURCE: &str = include_str!("../assets/opencode-plugin.js");
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OcHookKind {
SessionStart,
PromptSubmit,
Message,
StopWait,
SessionEnd,
ForkSpawned,
ForkCompleted,
}
#[derive(Debug, Deserialize)]
struct OcInput {
session_id: String,
directory: PathBuf,
#[serde(default)]
worktree: Option<PathBuf>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
context_tokens: Option<u64>,
#[serde(default)]
context_window: Option<u64>,
#[serde(default)]
fork: Option<String>,
#[serde(default)]
run_ref: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
busy: Option<bool>,
#[serde(default)]
waking: Option<bool>,
#[serde(default, rename = "continue")]
cont: Option<bool>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
bin: Option<PathBuf>,
}
pub fn run_hook(kind: OcHookKind) {
if run_hook_inner(kind).is_none() {
match kind {
OcHookKind::StopWait => println!("{{\"waited\":true}}"),
OcHookKind::Message => println!("{{}}"),
_ => {}
}
}
}
fn run_hook_inner(kind: OcHookKind) -> Option<()> {
let mut raw = String::new();
use std::io::Read;
std::io::stdin().read_to_string(&mut raw).ok()?;
let input: OcInput = serde_json::from_str(&raw).ok()?;
let paths = Paths::from_env()?;
let root = project_root_for(input.worktree.as_deref(), &input.directory);
let event = |ev: EventKind| Event {
event: ev,
session_id: input.session_id.clone(),
transcript_path: None,
cwd: input.directory.clone(),
project_root: root.clone(),
source: None,
reason: input.reason.clone(),
model: input.model.clone(),
enable_tags: crate::hook::tags_from_env("AUTOFORK_ENABLE_TAGS"),
disable_tags: crate::hook::tags_from_env("AUTOFORK_DISABLE_TAGS"),
waking: None,
notif_tool_use_id: None,
notif_task_id: None,
notif_status: None,
notif_continue: None,
context_tokens: input.context_tokens,
context_window: input.context_window,
client: Some(CLIENT.to_string()),
busy: input.busy,
harness: None,
};
match kind {
OcHookKind::SessionStart => {
let client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
let mut client = client.ensure_current_version(&paths).ok()?;
let _ = client.request(RequestBody::Event(event(EventKind::SessionStart)));
}
OcHookKind::PromptSubmit => {
let Ok(mut client) = Client::connect(&paths, Duration::from_millis(1500)) else {
spawn_daemon_detached(&paths);
return Some(());
};
let mut ev = event(EventKind::PromptSubmit);
ev.waking = Some(input.waking.unwrap_or(true));
let _ = client.request(RequestBody::Event(ev));
}
OcHookKind::Message => {
let client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
let mut client = client.ensure_current_version(&paths).ok()?;
let blocks = match client.request(RequestBody::TakeReports {
session_id: input.session_id.clone(),
wait_ms: Some(FEED_DRAIN_WAIT_MS),
}) {
Ok(ResponseBody::Reports { blocks }) => blocks,
_ => Vec::new(),
};
let out = serde_json::json!({ "context": { "blocks": blocks } });
println!("{out}");
}
OcHookKind::StopWait => {
let ppid0 = std::os::unix::process::parent_id();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(5));
if std::os::unix::process::parent_id() != ppid0 {
std::process::exit(0);
}
});
let client = Client::connect_or_spawn(&paths, Duration::from_secs(10)).ok()?;
let mut client = client.ensure_current_version(&paths).ok()?;
match client.stop_wait(event(EventKind::Stop)) {
Ok(ResponseBody::Wake {
payload,
forks,
feed,
}) => {
let out = serde_json::json!({
"wake": {
"payload": payload,
"forks": forks.unwrap_or_default(),
"feed": feed.as_ref().map(|f| serde_json::json!({
"blocks": f.blocks,
"wake": f.wake,
})),
}
});
println!("{out}");
}
_ => println!("{{\"waited\":true}}"),
}
}
OcHookKind::SessionEnd => {
let mut client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
let flush = {
let (cfg, _w) =
autofork_core::config::load_config_at(Some(&root), &paths.user_config());
cfg.flush_on_close
};
if flush {
if let Ok(ResponseBody::Due { forks }) =
client.request(RequestBody::TakeFinalRuns {
session_id: input.session_id.clone(),
})
{
crate::runner::spawn_final_runner(
&paths,
"opencode",
&input.session_id,
&input.session_id,
&input.directory,
input.model.as_deref(),
None,
input.bin.as_deref(),
&forks,
);
}
}
let _ = client.request(RequestBody::Event(event(EventKind::SessionEnd)));
}
OcHookKind::ForkSpawned => {
let mut client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
let _ = client.request(RequestBody::ForkSpawned {
session_id: input.session_id.clone(),
fork: input.fork.clone()?,
run_ref: input.run_ref.clone()?,
});
}
OcHookKind::ForkCompleted => {
let mut client = Client::connect_or_spawn(&paths, Duration::from_secs(5)).ok()?;
let _ = client.request(RequestBody::ForkCompleted {
session_id: input.session_id.clone(),
fork: input.fork.clone()?,
run_ref: input.run_ref.clone()?,
status: input.status.clone().unwrap_or_else(|| "completed".into()),
cont: input.cont,
});
}
}
Some(())
}
fn project_root_for(worktree: Option<&std::path::Path>, directory: &std::path::Path) -> PathBuf {
worktree
.filter(|w| *w != std::path::Path::new("/") && directory.starts_with(w))
.map(|w| w.to_path_buf())
.unwrap_or_else(|| directory.to_path_buf())
}
pub fn plugin_source() -> String {
PLUGIN_SOURCE.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))
}
pub fn opencode_config_dir() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(xdg).join("opencode"));
}
dirs_home().map(|h| h.join(".config").join("opencode"))
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
pub fn plugin_path() -> Option<PathBuf> {
Some(opencode_config_dir()?.join("plugin").join(PLUGIN_FILE))
}
pub fn install(print: bool) -> Result<(), String> {
if print {
print!("{}", plugin_source());
return Ok(());
}
let path = plugin_path().ok_or("cannot determine opencode config dir")?;
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
std::fs::write(&path, plugin_source())
.map_err(|e| format!("writing {}: {e}", path.display()))?;
println!("installed {}", path.display());
println!("restart opencode to load it (plugins load at instance start)");
Ok(())
}
pub fn uninstall() -> Result<(), String> {
let path = plugin_path().ok_or("cannot determine opencode config dir")?;
match std::fs::remove_file(&path) {
Ok(()) => {
println!("removed {}", path.display());
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!("not installed ({} absent)", path.display());
Ok(())
}
Err(e) => Err(format!("removing {}: {e}", path.display())),
}
}
pub fn doctor_lines() -> Vec<String> {
let mut lines = Vec::new();
let have_opencode = std::process::Command::new("opencode")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let Some(path) = plugin_path() else {
return lines;
};
match std::fs::read_to_string(&path) {
Ok(installed) => {
if installed != plugin_source() {
lines.push(format!(
"opencode plugin at {} is outdated or modified — run `autofork opencode install` to refresh it",
path.display()
));
} else {
lines.push(format!("opencode plugin installed ({})", path.display()));
}
if !have_opencode {
lines.push(
"opencode plugin is installed but `opencode` was not found on PATH".into(),
);
}
}
Err(_) => {
if have_opencode {
lines.push(
"opencode detected but the autofork plugin is not installed — run `autofork opencode install` to enable forks in opencode sessions"
.into(),
);
}
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_source_is_version_stamped() {
let src = plugin_source();
assert!(!src.contains("{{VERSION}}"));
assert!(src.contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn project_root_ignores_the_slash_worktree() {
use std::path::Path;
assert_eq!(
project_root_for(Some(Path::new("/repo")), Path::new("/repo/sub")),
Path::new("/repo")
);
assert_eq!(
project_root_for(Some(Path::new("/")), Path::new("/tmp/proj")),
Path::new("/tmp/proj")
);
assert_eq!(
project_root_for(Some(Path::new("/elsewhere")), Path::new("/tmp/proj")),
Path::new("/tmp/proj")
);
assert_eq!(
project_root_for(None, Path::new("/tmp/proj")),
Path::new("/tmp/proj")
);
}
#[test]
fn oc_input_parses_minimal_and_full() {
let min: OcInput = serde_json::from_str(r#"{"session_id":"s","directory":"/p"}"#).unwrap();
assert_eq!(min.session_id, "s");
assert!(min.model.is_none());
let full: OcInput = serde_json::from_str(
r#"{"session_id":"s","directory":"/p","worktree":"/w","model":"claude-haiku-4-5",
"context_tokens":1234,"context_window":1000000,
"fork":"journal","run_ref":"ses_x","status":"completed"}"#,
)
.unwrap();
assert_eq!(full.worktree.as_deref(), Some(std::path::Path::new("/w")));
assert_eq!(full.context_tokens, Some(1234));
assert_eq!(full.context_window, Some(1_000_000));
assert_eq!(full.status.as_deref(), Some("completed"));
}
}