Skip to content

Commit 243a947

Browse files
brentragerclaude
andcommitted
th-cc50cd: wire the OpenCode lifecycle plugin into th harness
enable opencode links ~/.config/opencode/plugins/smooth-agent.js into the smooth-agent plugin checkout (never-clobber: only symlinks are replaced, a real file is refused); status reports it; disable removes it under the same resolves-into-smooth-owned-sources rule as skills. Docs + changeset. Gates: cargo fmt/clippy clean, cargo test --features admin 686 passed (10 harness tests incl. the new link/refuse/disable case), pnpm test:hooks 21 passed + node plugin lifecycle test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 892d7dc commit 243a947

3 files changed

Lines changed: 102 additions & 7 deletions

File tree

.changeset/oc-lifecycle-plugin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@smooai/smooth': minor
3+
---
4+
5+
th-cc50cd: smooth-agent OpenCode lifecycle plugin — every OpenCode session now registers on the th-mail bus (placeholder handle `oc-<dir>-<sid4>`, pid-reaped), publishes working presence from tool activity (throttled), goes idle on session.idle and offline on session.deleted, degrading to a silent no-op without `th`. `th harness enable opencode` links it into `~/.config/opencode/plugins/` from the smooth-agent plugin checkout (same never-clobber/ownership rules as skills; `disable` removes it), `status` reports it, and the node smoke test is wired into `pnpm test:hooks`.

crates/smooth-cli/src/harness.rs

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,17 @@ fn enable(h: Harness, home: &Path) {
105105
statusline_step(home);
106106
}
107107
Harness::Codex => codex_plugin_step(home),
108-
Harness::OpenCode => match link_skills(home) {
109-
Ok((added, kept)) => println!(" skills: {added} linked, {kept} already current → {}", opencode_skills_dir(home).display()),
110-
Err(e) => println!(" skills: {} {e:#}", "FAILED".bright_red()),
111-
},
108+
Harness::OpenCode => {
109+
match link_skills(home) {
110+
Ok((added, kept)) => println!(" skills: {added} linked, {kept} already current → {}", opencode_skills_dir(home).display()),
111+
Err(e) => println!(" skills: {} {e:#}", "FAILED".bright_red()),
112+
}
113+
match link_opencode_plugin(home) {
114+
Ok(true) => println!(" plugin: lifecycle plugin linked → {}", opencode_plugin_link(home).display()),
115+
Ok(false) => println!(" plugin: already current"),
116+
Err(e) => println!(" plugin: {} {e:#}", "FAILED".bright_red()),
117+
}
118+
}
112119
}
113120
}
114121

@@ -210,6 +217,11 @@ fn status(h: Harness, home: &Path) {
210217
} else {
211218
println!(" skills: {n} linked");
212219
}
220+
if smooth_owned_link(&opencode_plugin_link(home), home) {
221+
println!(" plugin: lifecycle plugin linked");
222+
} else {
223+
println!(" plugin: not linked — `th harness enable opencode`");
224+
}
213225
}
214226
}
215227
}
@@ -224,6 +236,11 @@ fn disable(h: Harness, home: &Path) -> Result<()> {
224236
std::fs::remove_file(l).with_context(|| format!("remove {}", l.display()))?;
225237
}
226238
println!(" skills: {} smooth-owned links removed", links.len());
239+
let plugin = opencode_plugin_link(home);
240+
if smooth_owned_link(&plugin, home) {
241+
std::fs::remove_file(&plugin).with_context(|| format!("remove {}", plugin.display()))?;
242+
println!(" plugin: lifecycle plugin link removed");
243+
}
227244
}
228245
if h == Harness::ClaudeCode {
229246
println!(" plugin: left installed — remove with `claude plugin uninstall smooth-agent@smooth` if you mean it");
@@ -267,6 +284,50 @@ fn opencode_skills_dir(home: &Path) -> PathBuf {
267284
home.join(".opencode").join("skills")
268285
}
269286

287+
/// Where the OpenCode lifecycle plugin (th-cc50cd) gets linked: OpenCode
288+
/// auto-loads plugin files from `~/.config/opencode/plugins/`.
289+
fn opencode_plugin_link(home: &Path) -> PathBuf {
290+
home.join(".config").join("opencode").join("plugins").join("smooth-agent.js")
291+
}
292+
293+
/// Is this path a symlink resolving into smooth-owned sources (the Claude
294+
/// plugin cache / marketplace checkout)? The ownership rule for everything
295+
/// `disable` may remove.
296+
fn smooth_owned_link(path: &Path, home: &Path) -> bool {
297+
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
298+
&& std::fs::read_link(path).is_ok_and(|t| t.starts_with(home.join(".claude").join("plugins")))
299+
}
300+
301+
/// Link the smooth-agent OpenCode lifecycle plugin (session registration +
302+
/// presence on the th-mail bus). Same never-clobber rules as skills: only a
303+
/// symlink is ever replaced. Returns true when the link was (re)written.
304+
fn link_opencode_plugin(home: &Path) -> Result<bool> {
305+
let source = skill_source(home)
306+
.and_then(|skills| skills.parent().map(|root| root.join("opencode").join("smooth-agent.js")))
307+
.filter(|p| p.is_file())
308+
.context("smooth-agent plugin checkout has no opencode/smooth-agent.js — update the plugin first (th harness enable claude-code)")?;
309+
let link = opencode_plugin_link(home);
310+
if let Some(parent) = link.parent() {
311+
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
312+
}
313+
match std::fs::symlink_metadata(&link) {
314+
Ok(meta) if meta.file_type().is_symlink() => {
315+
if std::fs::read_link(&link).is_ok_and(|t| t == source) {
316+
return Ok(false);
317+
}
318+
std::fs::remove_file(&link)?;
319+
}
320+
Ok(_) => anyhow::bail!("{} exists and is not a symlink — left alone", link.display()),
321+
Err(_) => {}
322+
}
323+
#[cfg(unix)]
324+
std::os::unix::fs::symlink(&source, &link).with_context(|| format!("link {}", link.display()))?;
325+
#[cfg(not(unix))]
326+
anyhow::bail!("plugin link needs symlink support; copy {} manually", source.display());
327+
#[cfg(unix)]
328+
Ok(true)
329+
}
330+
270331
/// Symlink every canonical skill into the OpenCode skills dir. Never clobbers
271332
/// a real file/dir; repairs stale or dead symlinks. Returns (added, kept).
272333
fn link_skills(home: &Path) -> Result<(usize, usize)> {
@@ -413,6 +474,9 @@ mod tests {
413474
std::fs::create_dir_all(&d).unwrap();
414475
std::fs::write(d.join("SKILL.md"), "x").unwrap();
415476
}
477+
let oc = tmp.path().join(".claude/plugins/marketplaces/smooth/claude-plugins/smooth-agent/opencode");
478+
std::fs::create_dir_all(&oc).unwrap();
479+
std::fs::write(oc.join("smooth-agent.js"), "export const SmoothAgent = 1;").unwrap();
416480
tmp
417481
}
418482

@@ -512,6 +576,29 @@ mod tests {
512576
assert!(claude_statusline_wired(tmp.path()));
513577
}
514578

579+
#[test]
580+
#[cfg(unix)] // exercises real symlinks
581+
fn opencode_lifecycle_plugin_links_and_disables_cleanly() {
582+
let tmp = home();
583+
assert!(link_opencode_plugin(tmp.path()).unwrap(), "first run links");
584+
assert!(!link_opencode_plugin(tmp.path()).unwrap(), "second run is a no-op");
585+
let link = opencode_plugin_link(tmp.path());
586+
assert!(smooth_owned_link(&link, tmp.path()));
587+
588+
// A user's own real file of the same name is refused, not clobbered.
589+
std::fs::remove_file(&link).unwrap();
590+
std::fs::write(&link, "my own plugin").unwrap();
591+
assert!(link_opencode_plugin(tmp.path()).is_err());
592+
assert_eq!(std::fs::read_to_string(&link).unwrap(), "my own plugin");
593+
assert!(!smooth_owned_link(&link, tmp.path()), "a real file is never ours to remove");
594+
std::fs::remove_file(&link).unwrap();
595+
596+
// disable removes the link it owns.
597+
link_opencode_plugin(tmp.path()).unwrap();
598+
disable(Harness::OpenCode, tmp.path()).unwrap();
599+
assert!(std::fs::symlink_metadata(&link).is_err(), "disable must remove the plugin link");
600+
}
601+
515602
#[test]
516603
fn providers_expands_all_and_rejects_junk() {
517604
assert_eq!(providers("all").unwrap(), Harness::ALL.to_vec());

docs/Engineering/Using-th-CLI.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ th harness enable claude-code # smooth-agent plugin install/update (claude CLI
5858
# MCP server, statusline check
5959
th harness enable codex # MCP server + plugin state detection
6060
th harness enable opencode # MCP server + shared-skill symlinks (~/.opencode/skills)
61+
# + the lifecycle plugin (~/.config/opencode/plugins/smooth-agent.js):
62+
# every OpenCode session registers on the th-mail bus with
63+
# working/idle/offline presence (pearl th-cc50cd)
6164
th harness enable all
6265
th harness status # per-harness: MCP ok/stale/missing, plugin, skills, statusline
6366
th harness disable <provider> # removes ONLY what smooth wrote (MCP entry, smooth-owned links)
@@ -68,9 +71,9 @@ th harness disable <provider> # removes ONLY what smooth wrote (MCP entry, smo
6871
key order survive); `disable` never touches user-owned entries. The canonical
6972
skill source is the installed smooth-agent plugin checkout; Claude Code and
7073
Codex consume skills through the plugin itself, OpenCode gets symlinks.
71-
Lifecycle-hook parity for OpenCode (session registration, presence, inbox
72-
delivery) is pearl th-cc50cd; the read-only/approval-gated MCP tool split is
73-
pearl th-1d5ca8.
74+
Inbox delivery INTO a running OpenCode session (prompt-boundary context
75+
injection via the SDK client) is the remaining piece of pearl th-cc50cd; the
76+
write kill switch for `th mcp serve` is `SMOOTH_MCP_ALLOW_WRITE=0` (th-1d5ca8).
7477

7578
---
7679

0 commit comments

Comments
 (0)