diff --git a/Cargo.lock b/Cargo.lock index 980fcb5d96..450b36c747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,6 +694,7 @@ dependencies = [ "sha2", "tokio", "toml", + "toml_edit", "tracing", "tracing-subscriber", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index 84949e2de6..05fbdd0572 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ serde_json = "1" sha2 = "0.10" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } toml = "0.8" +toml_edit = "0.22" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } unicode-width = "0.2" diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 1a91870bab..c63008bb51 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -2120,7 +2120,8 @@ "cursor", "mastracode", "antigravity_cli", - "grok" + "grok", + "jcode" ], "type": "string" }, @@ -7115,7 +7116,8 @@ "cursor", "mastracode", "antigravity_cli", - "grok" + "grok", + "jcode" ], "type": "string" }, diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index 2bab31a55c..f8571652a5 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -32,6 +32,7 @@ Automatic detection works out of the box for common coding agents. The important | Antigravity CLI | screen manifest | session | | Kiro CLI | screen manifest | none | | Maki | screen manifest | none | +| Jcode | screen manifest | session | Detected but less thoroughly tested: Gemini CLI and Cline. Unsupported agents still run normally as terminal processes. They just may not get rich state unless you add an integration or report state over the socket API. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 254612e0c9..9d4121f821 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,6 +1,6 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Cursor Agent CLI, MastraCode, Antigravity CLI, Grok CLI, and Jcode. --- Herdr detects supported agents automatically. Official integrations can add native session identity for restore, lifecycle state reports, or both. @@ -28,6 +28,7 @@ herdr integration install cursor herdr integration install mastracode herdr integration install antigravity-cli herdr integration install grok +herdr integration install jcode ``` ## Uninstall integrations @@ -49,6 +50,7 @@ herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall antigravity-cli herdr integration uninstall grok +herdr integration uninstall jcode ``` ## How Herdr uses integrations @@ -58,13 +60,13 @@ Herdr uses integrations in two different ways: | Integration type | Agents | Effect | | --- | --- | --- | | Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI, Jcode | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | Custom socket integrations can also report state when they define state that is not visible in the native terminal UI. -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, Jcode, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, Jcode version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi @@ -288,6 +290,20 @@ Herdr uses `~/.grok` by default, or `GROK_HOME` when set. The Grok config direct After Grok emits a session start event, Herdr can use the reported session id to resume the pane with `grok --resume `. +## Jcode + +Install the Jcode hook: + +```bash +herdr integration install jcode +``` + +The hook reports native session identity through Jcode's `session_start` observer while Jcode runs inside a Herdr pane. Jcode state remains screen-driven: the composer marker and processing status line provide working and idle evidence, while shell mode and user-opened overlays remain idle. + +Herdr uses `~/.jcode`. The Jcode config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and appends it to `[hooks].session_start` in `config.toml`. Jcode runs each command in that lifecycle hook array directly. Reinstalling does not duplicate the Herdr command, and uninstall removes only the Herdr command while preserving every other observer. + +After Jcode emits a session start event, Herdr can resume the pane with `jcode --resume ` after a server restart. The integration reports session identity only and never reports lifecycle state. + ## Custom status labels Integrations report lifecycle state as semantic state only. For example, report an agent as `working` without adding display fields to the lifecycle report. diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index 468557b484..ec64fa263a 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -73,6 +73,7 @@ Native session restore requires these Herdr integration versions or newer: | Codex | `5` | `codex resume ` | | Cursor Agent CLI | `1` | `cursor-agent --resume ` | | Grok CLI | `1` | `grok --resume ` | +| Jcode | `1` | `jcode --resume ` | | GitHub Copilot CLI | `2` | `copilot --resume=` | | Devin CLI | `2` | `devin --resume ` | | Droid | `2` | `droid --resume ` | diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 5c9911b9d6..68c8f41981 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -1026,6 +1026,17 @@ "on", "off" ] + }, + { + "key": "ui.sound.agents.jcode", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Jcode agents.", + "values": [ + "default", + "on", + "off" + ] } ] }, diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 55b7d51ffd..b64a2e3cfa 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -89,6 +89,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { | ("herdr:qodercli", "qodercli") | ("herdr:cursor", "cursor") | ("herdr:grok", "grok") + | ("herdr:jcode", "jcode") ) } @@ -197,6 +198,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:grok", "grok", AgentSessionRefKind::Id) => { vec!["grok".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:jcode", "jcode", AgentSessionRefKind::Id) => { + vec!["jcode".into(), "--resume".into(), session_ref.value.clone()] + } _ => return None, }; @@ -233,6 +237,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:cursor", "cursor") | ("herdr:antigravity_cli", "agy") | ("herdr:grok", "grok") + | ("herdr:jcode", "jcode") ) } @@ -264,6 +269,7 @@ mod tests { assert!(is_reserved_native_state_source("herdr:claude", "claude")); assert!(is_reserved_native_state_source("herdr:codex", "codex")); assert!(is_reserved_native_state_source("herdr:devin", "devin")); + assert!(is_reserved_native_state_source("herdr:jcode", "jcode")); assert!(!is_reserved_native_state_source("herdr:kimi", "kimi")); assert!(!is_reserved_native_state_source( "herdr:opencode", @@ -435,6 +441,16 @@ mod tests { .argv, vec!["grok", "--resume", "grok-session"] ); + assert_eq!( + plan( + "herdr:jcode", + "jcode", + &AgentSessionRef::id("jcode-session").unwrap() + ) + .unwrap() + .argv, + vec!["jcode", "--resume", "jcode-session"] + ); } #[test] @@ -619,6 +635,9 @@ mod tests { let devin_plan = plan("herdr:devin", "devin", &AgentSessionRef::id(id).unwrap()).unwrap(); assert_eq!(devin_plan.argv, vec!["devin", "--resume", id]); + + let jcode_plan = plan("herdr:jcode", "jcode", &AgentSessionRef::id(id).unwrap()).unwrap(); + assert_eq!(jcode_plan.argv, vec!["jcode", "--resume", id]); } #[test] @@ -628,6 +647,7 @@ mod tests { let kilo_session = absolute_test_path("kilo-session"); let copilot_session = absolute_test_path("copilot-session"); let devin_session = absolute_test_path("devin-session"); + let jcode_session = absolute_test_path("jcode-session"); assert!(plan( "herdr:hermes", "hermes", @@ -658,6 +678,12 @@ mod tests { &AgentSessionRef::path(&devin_session).unwrap() ) .is_none()); + assert!(plan( + "herdr:jcode", + "jcode", + &AgentSessionRef::path(&jcode_session).unwrap() + ) + .is_none()); assert!(session_ref_from_snapshot( "herdr:mastracode", "mastracode", diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index ef55f8e295..b6a5c54e98 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -29,10 +29,11 @@ pub enum IntegrationTarget { Mastracode, AntigravityCli, Grok, + Jcode, } impl IntegrationTarget { - pub(crate) const ALL: [Self; 16] = [ + pub(crate) const ALL: [Self; 17] = [ Self::Pi, Self::Omp, Self::Claude, @@ -49,6 +50,7 @@ impl IntegrationTarget { Self::Mastracode, Self::AntigravityCli, Self::Grok, + Self::Jcode, ]; } diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 236cebe78d..2f96c2ebf5 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} " + "usage: herdr integration {action} " ); return Ok(None); } @@ -131,10 +131,11 @@ fn parse_integration_target( "mastracode" => IntegrationTarget::Mastracode, "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, "grok" => IntegrationTarget::Grok, + "jcode" => IntegrationTarget::Jcode, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, antigravity-cli, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, antigravity-cli, grok, jcode" ); return Ok(None); } @@ -161,6 +162,7 @@ fn print_integration_help() { eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration install antigravity-cli"); eprintln!(" herdr integration install grok"); + eprintln!(" herdr integration install jcode"); eprintln!(" herdr integration uninstall pi"); eprintln!(" herdr integration uninstall omp"); eprintln!(" herdr integration uninstall claude"); @@ -177,5 +179,6 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration uninstall antigravity-cli"); eprintln!(" herdr integration uninstall grok"); + eprintln!(" herdr integration uninstall jcode"); eprintln!(" herdr integration status [--outdated-only]"); } diff --git a/src/config/model.rs b/src/config/model.rs index e56ac08722..4ec493fe3d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -915,7 +915,7 @@ pub struct ExperimentalConfig { /// if the list contains no valid names, the reveal does not apply. /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline, /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, - /// qodercli, qoder, maki. + /// qodercli, qoder, maki, jcode. /// Default: empty. pub cjk_ime_agents: Vec, /// Cursor shape rendered for the IME anchor when diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index f7201134f9..f7ddb9268a 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -622,6 +622,7 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru Agent::Kilo, Agent::Qodercli, Agent::Maki, + Agent::Jcode, ]; let entries = agents .iter() diff --git a/src/config/sound.rs b/src/config/sound.rs index 217737d850..f7c3e6e393 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -44,6 +44,7 @@ pub struct AgentSoundOverrides { pub kilo: AgentSoundSetting, pub qodercli: AgentSoundSetting, pub maki: AgentSoundSetting, + pub jcode: AgentSoundSetting, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] @@ -140,6 +141,7 @@ impl AgentSoundOverrides { Some(Agent::Kilo) => self.kilo, Some(Agent::Qodercli) => self.qodercli, Some(Agent::Maki) => self.maki, + Some(Agent::Jcode) => self.jcode, None => AgentSoundSetting::Default, } } @@ -179,6 +181,7 @@ impl Default for AgentSoundOverrides { kilo: AgentSoundSetting::Default, qodercli: AgentSoundSetting::Default, maki: AgentSoundSetting::Default, + jcode: AgentSoundSetting::Default, } } } diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index ed3de50787..dcd47322ba 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -248,6 +248,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ ("gemini", include_str!("manifests/gemini.toml")), ("grok", include_str!("manifests/grok.toml")), ("hermes", include_str!("manifests/hermes.toml")), + ("jcode", include_str!("manifests/jcode.toml")), ("kilo", include_str!("manifests/kilo.toml")), ("kimi", include_str!("manifests/kimi.toml")), ("kiro", include_str!("manifests/kiro.toml")), diff --git a/src/detect/manifests/jcode.toml b/src/detect/manifests/jcode.toml new file mode 100644 index 0000000000..c2aa1c9595 --- /dev/null +++ b/src/detect/manifests/jcode.toml @@ -0,0 +1,47 @@ +id = "jcode" +version = "2026.08.03.1" +min_engine_version = 1 +updated_at = "2026-08-03T00:00:00Z" +aliases = ["j-code", "herdr:jcode"] + +# Jcode pins its composer to the bottom of the pane. The next message number is +# followed by one marker: `>` for ready, `…` while a turn is in flight, `»` for +# an armed skill, or `$` for shell mode. Only `…` means working. +[[rules]] +id = "composer_processing" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(4)" +visible_working = true +line_regex = ['^\s*\d+…'] + +# A multi-line composer can move its marker outside the recent-line window. +# Jcode's processing line then shows either a braille spinner or an animated +# three-dot tool bar directly above the composer. +[[rules]] +id = "status_line_spinner_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(4)" +visible_working = true +line_regex = ['^[\x{2800}-\x{28FF}] \S'] + +[[rules]] +id = "status_line_tool_bar_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(4)" +visible_working = true +line_regex = ['^[·●]{3} \S+ [·●]{3}(\s|$)'] + +[[rules]] +id = "composer_ready" +state = "idle" +priority = 850 +region = "bottom_non_empty_lines(4)" +visible_idle = true +line_regex = ['^\s*\d+[>»$]'] + +# Jcode emits no state-bearing OSC progress/title sequence. It also has no +# in-session approval prompt, so there is deliberately no blocked rule. Shell +# mode and user-opened overlays correctly remain idle. diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 29115dfffc..ecde844928 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -62,10 +62,11 @@ pub enum Agent { Kilo, Qodercli, Maki, + Jcode, } impl Agent { - pub const ALL: [Self; 21] = [ + pub const ALL: [Self; 22] = [ Self::Pi, Self::Claude, Self::Codex, @@ -87,9 +88,10 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Maki, + Self::Jcode, ]; - pub const SCREEN_MANIFEST_AGENTS: [Self; 19] = [ + pub const SCREEN_MANIFEST_AGENTS: [Self; 20] = [ Self::Pi, Self::Claude, Self::Codex, @@ -109,6 +111,7 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Maki, + Self::Jcode, ]; } @@ -135,6 +138,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Maki => "maki", + Agent::Jcode => "jcode", } } @@ -161,6 +165,7 @@ pub fn interactive_agent_executable(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Maki => "maki", + Agent::Jcode => "jcode", } } @@ -197,6 +202,7 @@ fn lookup_agent(name: &str) -> Option { "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), "maki" => Some(Agent::Maki), + "jcode" | "j-code" => Some(Agent::Jcode), _ => None, } } @@ -295,7 +301,7 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { matches!( (source, agent_label), - ("herdr:hermes", "hermes") | ("herdr:antigravity_cli", "agy") + ("herdr:hermes", "hermes") | ("herdr:antigravity_cli", "agy") | ("herdr:jcode", "jcode") ) } @@ -690,6 +696,7 @@ mod tests { assert_eq!(identify_agent("kilo"), Some(Agent::Kilo)); assert_eq!(identify_agent("kilo-code"), Some(Agent::Kilo)); assert_eq!(identify_agent("maki"), Some(Agent::Maki)); + assert_eq!(identify_agent("jcode"), Some(Agent::Jcode)); } #[test] @@ -716,6 +723,7 @@ mod tests { assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes)); assert_eq!(parse_agent_label("maki"), Some(Agent::Maki)); assert_eq!(parse_agent_label("kilo-code"), Some(Agent::Kilo)); + assert_eq!(parse_agent_label("j-code"), Some(Agent::Jcode)); } #[test] @@ -751,6 +759,7 @@ mod tests { (Agent::Kilo, "kilo"), (Agent::Qodercli, "qodercli"), (Agent::Maki, "maki"), + (Agent::Jcode, "jcode"), ]; assert_eq!(expected.len(), Agent::ALL.len()); for (agent, executable) in expected { @@ -780,6 +789,7 @@ mod tests { for (source, label, agent) in [ ("herdr:hermes", "hermes", Agent::Hermes), ("herdr:antigravity_cli", "agy", Agent::Antigravity), + ("herdr:jcode", "jcode", Agent::Jcode), ] { assert!(!full_lifecycle_hook_authority(source, label)); assert!(session_identity_only_integration(source, label)); @@ -787,6 +797,25 @@ mod tests { } } + #[test] + fn jcode_manifest_uses_composer_and_processing_status_evidence() { + for screen in [ + "transcript\n2…", + "transcript\n⠼ waiting for response… 1s\nmultiline composer", + "transcript\n··● bash ●·· · run tests · 23s\nmultiline composer", + ] { + let detection = detect_agent(Some(Agent::Jcode), screen); + assert_eq!(detection.state, AgentState::Working, "screen: {screen}"); + assert!(detection.visible_working); + } + + for screen in ["1>", "6$", "6»"] { + let detection = detect_agent(Some(Agent::Jcode), screen); + assert_eq!(detection.state, AgentState::Idle, "screen: {screen}"); + assert!(detection.visible_idle); + } + } + #[test] fn identify_unknown_processes() { assert_eq!(identify_agent("bash"), None); diff --git a/src/integration/actions.rs b/src/integration/actions.rs index 3cb737744f..3a4cd3c7dd 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -3,12 +3,12 @@ use std::io; use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, - install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, - install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + install_devin, install_droid, install_grok, install_hermes, install_jcode, install_kilo, + install_kimi, install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, - uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, - uninstall_pi, uninstall_qodercli, + uninstall_jcode, uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, + uninstall_opencode, uninstall_pi, uninstall_qodercli, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -231,6 +231,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::Jcode => { + let installed = install_jcode()?; + vec![ + format!( + "installed jcode integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured jcode session_start hook at {}", + installed.config_path.display() + ), + ] + } }; if let Some(warning) = version_warning { @@ -639,6 +652,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Jcode => { + let result = uninstall_jcode()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed jcode hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no jcode hook found at {}", + result.hook_path.display() + )); + } + if result.updated_config { + messages.push(format!( + "updated jcode session_start hook in {}", + result.config_path.display() + )); + } else { + messages.push(format!( + "no herdr jcode hook entry found in {}", + result.config_path.display() + )); + } + messages + } }; crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); diff --git a/src/integration/assets/jcode/herdr-agent-state.sh b/src/integration/assets/jcode/herdr-agent-state.sh new file mode 100644 index 0000000000..468dfbb970 --- /dev/null +++ b/src/integration/assets/jcode/herdr-agent-state.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=jcode +# HERDR_INTEGRATION_VERSION=1 + +command -v python3 >/dev/null 2>&1 || exit 0 +python3 - <<'PY' +import json +import os +import socket +import time + +if os.environ.get("HERDR_ENV") != "1": + raise SystemExit(0) + +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +session_id = os.environ.get("JCODE_HOOK_SESSION_ID") +if not pane_id or not socket_path or not session_id: + raise SystemExit(0) + +hook_source = os.environ.get("JCODE_HOOK_SOURCE") +if hook_source in ("create", "attach"): + session_start_source = "startup" +elif hook_source == "resume": + session_start_source = "resume" +else: + session_start_source = None + +seq = time.time_ns() +params = { + "pane_id": pane_id, + "source": "herdr:jcode", + "agent": "jcode", + "seq": seq, + "agent_session_id": session_id, +} +if session_start_source is not None: + params["session_start_source"] = session_start_source + +request = json.dumps( + { + "id": f"herdr:jcode:{seq}", + "method": "pane.report_agent_session", + "params": params, + } +) + +try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((request + "\n").encode()) + client.recv(4096) +except Exception: + pass +PY diff --git a/src/integration/config_edit.rs b/src/integration/config_edit.rs index 630682ce4f..ffaa8fb22c 100644 --- a/src/integration/config_edit.rs +++ b/src/integration/config_edit.rs @@ -2,6 +2,7 @@ use std::io; use std::path::Path; use serde_json::{json, Map, Value}; +use toml_edit::{value, Array, DocumentMut, Item, Table}; use super::command::{hook_command, legacy_bash_hook_command}; #[cfg(windows)] @@ -810,6 +811,139 @@ pub(crate) fn remove_kimi_config_block(content: &str) -> String { } } +pub(crate) fn jcode_session_start_commands( + content: &str, + config_path: &Path, +) -> io::Result> { + let document = parse_jcode_config(content, config_path)?; + let Some(entry) = document + .get("hooks") + .and_then(Item::as_table_like) + .and_then(|hooks| hooks.get("session_start")) + else { + return Ok(Vec::new()); + }; + if let Some(command) = entry.as_str() { + return Ok(vec![command.to_string()]); + } + let Some(commands) = entry.as_array() else { + return Err(invalid_jcode_session_start(config_path)); + }; + commands + .iter() + .map(|command| { + command + .as_str() + .map(str::to_string) + .ok_or_else(|| invalid_jcode_session_start(config_path)) + }) + .collect() +} + +pub(crate) fn append_jcode_session_start_command( + content: &str, + config_path: &Path, + command: &str, +) -> io::Result { + let commands = jcode_session_start_commands(content, config_path)?; + if commands.iter().any(|existing| existing == command) { + return Ok(content.to_string()); + } + + let mut document = parse_jcode_config(content, config_path)?; + if !document.contains_key("hooks") { + document.insert("hooks", Item::Table(Table::new())); + } + let hooks = document + .get_mut("hooks") + .and_then(Item::as_table_like_mut) + .ok_or_else(|| { + io::Error::other(format!( + "jcode hooks at {} must be a TOML table", + config_path.display() + )) + })?; + match hooks.get_mut("session_start") { + Some(entry) if entry.is_str() => { + let mut updated = Array::new(); + for existing in commands { + updated.push(existing); + } + updated.push(command); + *entry = value(updated); + } + Some(entry) => entry + .as_array_mut() + .ok_or_else(|| invalid_jcode_session_start(config_path))? + .push(command), + None => { + let mut commands = Array::new(); + commands.push(command); + hooks.insert("session_start", value(commands)); + } + } + Ok(document.to_string()) +} + +pub(crate) fn remove_jcode_session_start_command( + content: &str, + config_path: &Path, + command: &str, +) -> io::Result { + let commands = jcode_session_start_commands(content, config_path)?; + if !commands.iter().any(|existing| existing == command) { + return Ok(content.to_string()); + } + + let mut document = parse_jcode_config(content, config_path)?; + let remove_empty_hooks = { + let hooks = document + .get_mut("hooks") + .and_then(Item::as_table_like_mut) + .ok_or_else(|| { + io::Error::other(format!( + "jcode hooks at {} must be a TOML table", + config_path.display() + )) + })?; + let remove_session_start = { + let entry = hooks + .get_mut("session_start") + .ok_or_else(|| invalid_jcode_session_start(config_path))?; + if entry.is_str() { + true + } else { + let commands = entry + .as_array_mut() + .ok_or_else(|| invalid_jcode_session_start(config_path))?; + commands.retain(|existing| existing.as_str() != Some(command)); + commands.is_empty() + } + }; + if remove_session_start { + hooks.remove("session_start"); + } + hooks.is_empty() + }; + if remove_empty_hooks { + document.remove("hooks"); + } + Ok(document.to_string()) +} + +fn invalid_jcode_session_start(config_path: &Path) -> io::Error { + io::Error::other(format!( + "jcode hooks.session_start at {} must be a string or array of strings", + config_path.display() + )) +} + +fn parse_jcode_config(content: &str, config_path: &Path) -> io::Result { + content.parse::().map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", config_path.display())) + }) +} + pub(crate) fn toml_basic_string(value: &str) -> String { let mut result = String::with_capacity(value.len() + 2); result.push('"'); diff --git a/src/integration/env.rs b/src/integration/env.rs index d76ce66cb9..62f225252c 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -19,6 +19,7 @@ pub(crate) const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; pub(crate) const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR"; pub(crate) const ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR: &str = "ANTIGRAVITY_CLI_CONFIG_DIR"; pub(crate) const GROK_CONFIG_DIR_ENV_VAR: &str = "GROK_CONFIG_DIR"; +pub(crate) const JCODE_HOME_ENV_VAR: &str = "JCODE_HOME"; /// The grok CLI's own config-home override (documented alongside /// `$GROK_HOME/config.toml` and `$GROK_HOME/auth.json`). pub(crate) const GROK_HOME_ENV_VAR: &str = "GROK_HOME"; @@ -78,6 +79,10 @@ pub(crate) fn droid_dir() -> io::Result { Ok(home_dir()?.join(".factory")) } +pub(crate) fn jcode_dir() -> io::Result { + config_dir_from_env_or_home(JCODE_HOME_ENV_VAR, &[".jcode"]) +} + pub(crate) fn config_dir_from_env_or_home( env_var: &str, home_relative_segments: &[&str], diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 8be54c8406..2a2a89daab 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -246,6 +246,9 @@ const GROK_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const GROK_HOOK_CONFIG_INSTALL_NAME: &str = "herdr.json"; const GROK_HOOK_ASSET: &str = include_str!("assets/grok/herdr-agent-state.sh"); const GROK_INTEGRATION_VERSION: u32 = 1; +const JCODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const JCODE_HOOK_ASSET: &str = include_str!("assets/jcode/herdr-agent-state.sh"); +const JCODE_INTEGRATION_VERSION: u32 = 1; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 7041eb1b6e..3565bcebf7 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -24,6 +24,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Mastracode => "mastracode", crate::api::schema::IntegrationTarget::AntigravityCli => "antigravity-cli", crate::api::schema::IntegrationTarget::Grok => "grok", + crate::api::schema::IntegrationTarget::Jcode => "jcode", } } @@ -53,6 +54,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], crate::api::schema::IntegrationTarget::AntigravityCli => &["agy"], crate::api::schema::IntegrationTarget::Grok => &["grok"], + crate::api::schema::IntegrationTarget::Jcode => &["jcode"], } } @@ -262,7 +264,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, -); 16] { +); 17] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -350,6 +352,11 @@ fn integration_specs() -> [( grok_dir().map(|dir| dir.join("hooks").join(super::GROK_HOOK_INSTALL_NAME)), super::GROK_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Jcode, + jcode_dir().map(|dir| dir.join("hooks").join(super::JCODE_HOOK_INSTALL_NAME)), + super::JCODE_INTEGRATION_VERSION, + ), ] } @@ -403,6 +410,22 @@ fn grok_hook_config_is_valid(hook_path: &Path) -> bool { .is_some_and(|config| config == super::targets::grok_hook_config(hook_path)) } +fn jcode_hook_config_is_valid(hook_path: &Path) -> bool { + let Some(jcode_dir) = hook_path.parent().and_then(Path::parent) else { + return false; + }; + let config_path = jcode_dir.join("config.toml"); + fs::read_to_string(&config_path) + .ok() + .and_then(|content| { + super::config_edit::jcode_session_start_commands(&content, &config_path).ok() + }) + .is_some_and(|commands| { + let hook_command = super::targets::jcode_hook_command(hook_path); + commands.iter().any(|command| command == &hook_command) + }) +} + pub(crate) fn integration_status_at( target: crate::api::schema::IntegrationTarget, path: PathBuf, @@ -437,6 +460,14 @@ pub(crate) fn integration_status_at( { state = super::IntegrationStatusKind::Outdated; } + if target == crate::api::schema::IntegrationTarget::Jcode + && state == super::IntegrationStatusKind::Current + && !jcode_hook_config_is_valid(&path) + { + // Jcode only invokes the adapter while session_start includes the + // managed hook, so a broken config is nonfunctional. + state = super::IntegrationStatusKind::Outdated; + } super::IntegrationStatus { target, diff --git a/src/integration/targets.rs b/src/integration/targets.rs index b387b960a9..6870953ac8 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -9,16 +9,17 @@ use super::claude_settings::{ }; use super::command::{hook_command, shell_single_quote}; use super::config_edit::{ - build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook, - ensure_direct_command_hook, ensure_flat_command_hook, ensure_hermes_plugin_enabled, - ensure_hooks_object, ensure_simple_command_hook, hooks_object_if_present, - remove_direct_hook_commands, remove_flat_command_hook, remove_hermes_plugin_enabled, - remove_hook_commands, remove_kimi_config_block, remove_simple_command_hook, + append_jcode_session_start_command, build_codex_config_with_hooks, + build_kimi_config_with_hooks, ensure_command_hook, ensure_direct_command_hook, + ensure_flat_command_hook, ensure_hermes_plugin_enabled, ensure_hooks_object, + ensure_simple_command_hook, hooks_object_if_present, remove_direct_hook_commands, + remove_flat_command_hook, remove_hermes_plugin_enabled, remove_hook_commands, + remove_jcode_session_start_command, remove_kimi_config_block, remove_simple_command_hook, }; use super::env::{ antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, - grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, - opencode_dir, pi_extension_dir, qodercli_dir, + grok_dir, hermes_dir, hermes_plugin_dir, jcode_dir, kilo_dir, kimi_dir, mastracode_dir, + omp_extension_dir, opencode_dir, pi_extension_dir, qodercli_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, @@ -28,10 +29,11 @@ use super::types::{ ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths, - GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, - KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, - MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, - OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, + GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, JcodeInstallPaths, + JcodeUninstallResult, KiloInstallPaths, KiloUninstallResult, KimiInstallPaths, + KimiUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths, + OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, + QodercliInstallPaths, QodercliUninstallResult, }; use super::{ ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, @@ -43,13 +45,13 @@ use super::{ DROID_HOOK_EVENTS, DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, - HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, - KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, - MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, - OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, - OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, - QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, - QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, + HERMES_PLUGIN_MANIFEST_INSTALL_NAME, JCODE_HOOK_ASSET, JCODE_HOOK_INSTALL_NAME, + KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, + MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, + MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, OMP_EXTENSION_ASSET, + OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, + PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, + QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, }; fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { @@ -74,6 +76,45 @@ pub(crate) fn install_pi() -> io::Result { Ok(path) } +pub(crate) fn jcode_hook_command(hook_path: &Path) -> String { + hook_command(hook_path, None) +} + +pub(crate) fn install_jcode() -> io::Result { + let dir = jcode_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "jcode config directory not found at {}. install jcode first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + let hook_path = hooks_dir.join(JCODE_HOOK_INSTALL_NAME); + let config_path = dir.join("config.toml"); + let existing_config = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + String::new() + }; + let command = jcode_hook_command(&hook_path); + let updated_config = + append_jcode_session_start_command(&existing_config, &config_path, &command)?; + + // Validate the user's config before creating any managed filesystem state. + fs::create_dir_all(&hooks_dir)?; + fs::write(&hook_path, JCODE_HOOK_ASSET)?; + make_executable(&hook_path)?; + if updated_config != existing_config { + fs::write(&config_path, updated_config)?; + } + + Ok(JcodeInstallPaths { + hook_path, + config_path, + }) +} + pub(crate) fn install_omp() -> io::Result { let dir = omp_extension_dir()?; let pi_dir = pi_extension_dir()?; @@ -1319,3 +1360,30 @@ pub(crate) fn uninstall_grok() -> io::Result { removed_config_file, }) } + +pub(crate) fn uninstall_jcode() -> io::Result { + let dir = jcode_dir()?; + let hooks_dir = dir.join("hooks"); + let hook_path = hooks_dir.join(JCODE_HOOK_INSTALL_NAME); + let config_path = dir.join("config.toml"); + let mut updated_config = false; + + if config_path.is_file() { + let existing_config = fs::read_to_string(&config_path)?; + let command = jcode_hook_command(&hook_path); + let new_config = + remove_jcode_session_start_command(&existing_config, &config_path, &command)?; + if new_config != existing_config { + fs::write(&config_path, new_config)?; + updated_config = true; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + Ok(JcodeUninstallResult { + hook_path, + config_path, + removed_hook_file, + updated_config, + }) +} diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 6827dae710..de071e6459 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -2652,6 +2652,7 @@ fn bundled_integration_asset_versions_match_expected_versions() { MASTRACODE_HOOK_ASSET, MASTRACODE_INTEGRATION_VERSION, ), + ("jcode", JCODE_HOOK_ASSET, JCODE_INTEGRATION_VERSION), ] { assert_eq!( parse_integration_version(asset), @@ -2781,6 +2782,12 @@ fn bundled_integration_assets_report_session_refs() { assert!(GROK_HOOK_ASSET.contains("herdr:grok")); assert!(!GROK_HOOK_ASSET.contains("\"state\":")); assert!(!GROK_HOOK_ASSET.contains("pane.release_agent")); + assert!(JCODE_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=jcode")); + assert!(JCODE_HOOK_ASSET.contains("JCODE_HOOK_SESSION_ID")); + assert!(JCODE_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(JCODE_HOOK_ASSET.contains("herdr:jcode")); + assert!(!JCODE_HOOK_ASSET.contains("pane.report_agent\"")); + assert!(!JCODE_HOOK_ASSET.contains("pane.release_agent")); } #[test] @@ -3970,3 +3977,253 @@ fn grok_dir_honors_grok_home_after_config_dir_seam() { clear_integration_path_env(); let _ = fs::remove_dir_all(base); } + +#[cfg(unix)] +#[test] +fn install_and_uninstall_jcode_preserve_existing_session_hook() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let jcode = home.join(".jcode"); + fs::create_dir_all(&jcode).unwrap(); + let config_path = jcode.join("config.toml"); + let previous = "~/bin/session-observer --label 'user hook'"; + fs::write( + &config_path, + format!( + "# keep this comment\n[display]\nemoji = false\n\n[hooks]\nturn_end = \"notify\"\nsession_start = {}\n", + toml_basic_string(previous) + ), + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_jcode().unwrap(); + let installed_config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + jcode_session_start_commands(&installed_config, &config_path).unwrap(), + vec![ + previous.to_string(), + jcode_hook_command(&installed.hook_path) + ] + ); + assert!(installed_config.contains("# keep this comment")); + assert!(installed_config.contains("turn_end = \"notify\"")); + + // Reinstalling must not duplicate Herdr's command or disturb user commands. + install_jcode().unwrap(); + let reinstalled_config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + jcode_session_start_commands(&reinstalled_config, &config_path).unwrap(), + vec![ + previous.to_string(), + jcode_hook_command(&installed.hook_path) + ] + ); + assert_eq!( + integration_status_at( + crate::api::schema::IntegrationTarget::Jcode, + installed.hook_path.clone(), + JCODE_INTEGRATION_VERSION, + ) + .state, + IntegrationStatusKind::Current + ); + + let result = uninstall_jcode().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_config); + let restored_config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + jcode_session_start_commands(&restored_config, &config_path).unwrap(), + vec![previous.to_string()] + ); + assert!(restored_config.contains("# keep this comment")); + assert!(restored_config.contains("turn_end = \"notify\"")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn install_and_uninstall_jcode_preserve_existing_session_hook_array() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let jcode = home.join(".jcode"); + fs::create_dir_all(&jcode).unwrap(); + let config_path = jcode.join("config.toml"); + fs::write( + &config_path, + "[hooks]\nsession_start = [\"first\", \"second\"]\nturn_end = \"notify\"\n", + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_jcode().unwrap(); + install_jcode().unwrap(); + let installed_config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + jcode_session_start_commands(&installed_config, &config_path).unwrap(), + vec![ + "first".to_string(), + "second".to_string(), + jcode_hook_command(&installed.hook_path), + ] + ); + + let result = uninstall_jcode().unwrap(); + assert!(result.updated_config); + let remaining_config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + jcode_session_start_commands(&remaining_config, &config_path).unwrap(), + vec!["first".to_string(), "second".to_string()] + ); + assert!(remaining_config.contains("turn_end = \"notify\"")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn uninstall_jcode_does_not_clobber_a_user_replacement_hook() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let jcode = home.join(".jcode"); + fs::create_dir_all(&jcode).unwrap(); + let config_path = jcode.join("config.toml"); + fs::write(&config_path, "[hooks]\nsession_start = \"first\"\n").unwrap(); + std::env::set_var("HOME", &home); + + install_jcode().unwrap(); + fs::write( + &config_path, + "[hooks]\nsession_start = [\"first\", \"second\"]\n", + ) + .unwrap(); + + let result = uninstall_jcode().unwrap(); + assert!(!result.updated_config); + let remaining = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + jcode_session_start_commands(&remaining, &config_path).unwrap(), + vec!["first".to_string(), "second".to_string()] + ); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_jcode_rejects_session_hook_arrays_with_non_string_values() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let jcode = home.join(".jcode"); + fs::create_dir_all(&jcode).unwrap(); + let config_path = jcode.join("config.toml"); + fs::write(&config_path, "[hooks]\nsession_start = [\"first\", 42]\n").unwrap(); + std::env::set_var("HOME", &home); + + let error = install_jcode().unwrap_err(); + assert!(error + .to_string() + .contains("must be a string or array of strings")); + assert_eq!( + fs::read_to_string(&config_path).unwrap(), + "[hooks]\nsession_start = [\"first\", 42]\n" + ); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn jcode_hook_reports_official_session() { + use std::io::{BufRead, Write}; + use std::os::unix::net::UnixListener; + + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let jcode = home.join(".jcode"); + fs::create_dir_all(&jcode).unwrap(); + std::env::set_var("HOME", &home); + let installed = install_jcode().unwrap(); + let config_path = jcode.join("config.toml"); + assert_eq!( + jcode_session_start_commands(&fs::read_to_string(&config_path).unwrap(), &config_path) + .unwrap(), + vec![jcode_hook_command(&installed.hook_path)] + ); + + let socket_path = PathBuf::from(format!("/tmp/herdr-jcode-{}.sock", std::process::id())); + let _ = fs::remove_file(&socket_path); + let listener = UnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut requests = Vec::new(); + while requests.len() < 2 { + match listener.accept() { + Ok((mut stream, _)) => { + let mut line = String::new(); + std::io::BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + stream + .write_all(b"{\"id\":\"ok\",\"result\":{}}\n") + .unwrap(); + requests.push(serde_json::from_str::(&line).unwrap()); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "two jcode hooks did not connect to the Herdr socket within 5 seconds" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("failed to accept Jcode hook connection: {error}"), + } + } + requests + }); + + for (pane, session) in [ + ("w1:p2", "jcode-session-123"), + ("w1:p3", "jcode-session-456"), + ] { + let status = std::process::Command::new("bash") + .arg(&installed.hook_path) + .env("HERDR_ENV", "1") + .env("HERDR_PANE_ID", pane) + .env("HERDR_SOCKET_PATH", &socket_path) + .env("JCODE_HOOK_SESSION_ID", session) + .env("JCODE_HOOK_SOURCE", "resume") + .status() + .unwrap(); + assert!(status.success()); + } + + let requests = server.join().unwrap(); + let request = &requests[0]; + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["source"], "herdr:jcode"); + assert_eq!(request["params"]["agent"], "jcode"); + assert_eq!(request["params"]["pane_id"], "w1:p2"); + assert_eq!(request["params"]["agent_session_id"], "jcode-session-123"); + assert_eq!(request["params"]["session_start_source"], "resume"); + assert_eq!(requests[1]["params"]["pane_id"], "w1:p3"); + assert_eq!( + requests[1]["params"]["agent_session_id"], + "jcode-session-456" + ); + + let _ = fs::remove_file(&socket_path); + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} diff --git a/src/integration/types.rs b/src/integration/types.rs index 09a235b047..64f1fad58e 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -109,6 +109,20 @@ pub(crate) struct GrokUninstallResult { pub removed_config_file: bool, } +#[derive(Debug)] +pub(crate) struct JcodeInstallPaths { + pub hook_path: PathBuf, + pub config_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct JcodeUninstallResult { + pub hook_path: PathBuf, + pub config_path: PathBuf, + pub removed_hook_file: bool, + pub updated_config: bool, +} + #[derive(Debug)] pub(crate) struct QodercliUninstallResult { pub hook_path: PathBuf, diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs index 21e7036b96..3dc0bd089d 100644 --- a/tests/cli/hooks.rs +++ b/tests/cli/hooks.rs @@ -1,5 +1,114 @@ use super::harness::*; +// Shell integrations belong in `shell_hooks_conform_to_session_report_contract`. +// The table exercises the asset against a real Unix socket and checks Herdr's +// stable session-report contract. Keep adapter-specific edge cases as separate +// tests below, and add a shared-socket case when an agent can have concurrent +// panes (as Jcode does). +#[derive(Clone, Copy)] +struct ShellHookInvocation<'a> { + asset_path: &'a str, + args: &'a [&'a str], + hook_input: &'a str, + envs: &'a [(&'a str, &'a str)], + pane_id: &'a str, +} + +pub(super) struct FakeHookSocket { + base: PathBuf, + socket_path: PathBuf, + server: thread::JoinHandle>, +} + +impl FakeHookSocket { + pub(super) fn start(expected_requests: usize) -> Self { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_millis(700); + let mut requests = Vec::with_capacity(expected_requests); + while requests.len() < expected_requests && Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); + let _ = stream.write_all(b"\n"); + let _ = stream.flush(); + requests.push(serde_json::from_str(&line).unwrap()); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept failed: {err}"), + } + } + requests + }); + + Self { + base, + socket_path, + server, + } + } + + pub(super) fn path(&self) -> &Path { + &self.socket_path + } + + pub(super) fn finish(self) -> Vec { + let requests = self.server.join().unwrap(); + cleanup_test_base(&self.base); + requests + } +} + +fn invoke_shell_hook(invocation: &ShellHookInvocation<'_>, socket_path: &Path) { + let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(invocation.asset_path); + let mut command = Command::new("bash"); + command + .arg(hook_path) + .args(invocation.args) + .env("HERDR_ENV", "1") + .env("HERDR_SOCKET_PATH", socket_path) + .env("HERDR_PANE_ID", invocation.pane_id) + .env_remove("CODEX_THREAD_ID") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in invocation.envs { + command.env(key, value); + } + let mut child = command.spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(invocation.hook_input.as_bytes()).unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "hook failed: asset={} status={:?} stderr={} stdout={}", + invocation.asset_path, + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); +} + +fn run_shell_hooks(invocations: &[ShellHookInvocation<'_>]) -> Vec { + let socket = FakeHookSocket::start(invocations.len()); + for invocation in invocations { + invoke_shell_hook(invocation, &socket.socket_path); + } + socket.finish() +} + fn run_claude_hook(action: &str, hook_input: &str) -> Option { run_shell_hook( "src/integration/assets/claude/herdr-agent-state.sh", @@ -47,66 +156,154 @@ fn run_shell_hook_with_env( hook_input: &str, envs: &[(&str, &str)], ) -> Option { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(700); - while Instant::now() < deadline { - match listener.accept() { - Ok((mut stream, _)) => { - let mut line = String::new(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - reader.read_line(&mut line).unwrap(); - let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); - let _ = stream.write_all(b"\n"); - let _ = stream.flush(); - return Some(line); - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("accept failed: {err}"), - } - } - None - }); + let socket = FakeHookSocket::start(1); + invoke_shell_hook( + &ShellHookInvocation { + asset_path, + args, + hook_input, + envs, + pane_id: "p_test", + }, + &socket.socket_path, + ); + socket.finish().into_iter().next() +} - let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path); - let mut command = Command::new("bash"); - command - .arg(hook_path) - .args(args) - .env("HERDR_ENV", "1") - .env("HERDR_SOCKET_PATH", &socket_path) - .env("HERDR_PANE_ID", "p_test") - .env_remove("CODEX_THREAD_ID") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - for (key, value) in envs { - command.env(key, value); +#[test] +fn shell_hooks_conform_to_session_report_contract() { + struct Case<'a> { + name: &'a str, + invocation: ShellHookInvocation<'a>, + agent: &'a str, + session_id: &'a str, } - let mut child = command.spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - stdin.write_all(hook_input.as_bytes()).unwrap(); - drop(stdin); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "hook failed: status={:?} stderr={} stdout={}", - output.status.code(), - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); + let cases = [ + Case { + name: "claude", + invocation: ShellHookInvocation { + asset_path: "src/integration/assets/claude/herdr-agent-state.sh", + args: &["session"], + hook_input: r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#, + envs: &[], + pane_id: "p_claude", + }, + agent: "claude", + session_id: "claude-session", + }, + Case { + name: "codex", + invocation: ShellHookInvocation { + asset_path: "src/integration/assets/codex/herdr-agent-state.sh", + args: &["session"], + hook_input: r#"{"hook_event_name":"SessionStart","session_id":"codex-session","transcript_path":"/tmp/codex-session.jsonl"}"#, + envs: &[], + pane_id: "p_codex", + }, + agent: "codex", + session_id: "codex-session", + }, + Case { + name: "copilot", + invocation: ShellHookInvocation { + asset_path: "src/integration/assets/copilot/herdr-agent-state.sh", + args: &[], + hook_input: r#"{"hook_event_name":"SessionStart","session_id":"copilot-session"}"#, + envs: &[], + pane_id: "p_copilot", + }, + agent: "copilot", + session_id: "copilot-session", + }, + Case { + name: "devin", + invocation: ShellHookInvocation { + asset_path: "src/integration/assets/devin/herdr-agent-state.sh", + args: &["session"], + hook_input: r#"{"hook_event_name":"SessionStart","session_id":"devin-session"}"#, + envs: &[], + pane_id: "p_devin", + }, + agent: "devin", + session_id: "devin-session", + }, + Case { + name: "jcode", + invocation: ShellHookInvocation { + asset_path: "src/integration/assets/jcode/herdr-agent-state.sh", + args: &[], + hook_input: "", + envs: &[("JCODE_HOOK_SESSION_ID", "jcode-session")], + pane_id: "p_jcode", + }, + agent: "jcode", + session_id: "jcode-session", + }, + ]; + + for case in cases { + let mut requests = run_shell_hooks(&[case.invocation]); + let request = requests + .pop() + .unwrap_or_else(|| panic!("{} hook did not report a session", case.name)); + assert_eq!( + request["method"], "pane.report_agent_session", + "{}", + case.name + ); + assert_eq!(request["params"]["agent"], case.agent, "{}", case.name); + assert_eq!( + request["params"]["pane_id"], case.invocation.pane_id, + "{}", + case.name + ); + assert_eq!( + request["params"]["agent_session_id"], case.session_id, + "{}", + case.name + ); + assert!( + request["params"].get("state").is_none(), + "{} session report included lifecycle state", + case.name + ); + } +} - let request = server.join().unwrap(); - cleanup_test_base(&base); - request.map(|line| serde_json::from_str(&line).unwrap()) +#[test] +fn jcode_hook_maps_two_panes_to_distinct_sessions_on_one_socket() { + let invocations = [ + ShellHookInvocation { + asset_path: "src/integration/assets/jcode/herdr-agent-state.sh", + args: &[], + hook_input: "", + envs: &[("JCODE_HOOK_SESSION_ID", "jcode-session-one")], + pane_id: "p_jcode_one", + }, + ShellHookInvocation { + asset_path: "src/integration/assets/jcode/herdr-agent-state.sh", + args: &[], + hook_input: "", + envs: &[("JCODE_HOOK_SESSION_ID", "jcode-session-two")], + pane_id: "p_jcode_two", + }, + ]; + + let requests = run_shell_hooks(&invocations); + let mappings: std::collections::HashMap<_, _> = requests + .iter() + .map(|request| { + ( + request["params"]["pane_id"].as_str().unwrap(), + request["params"]["agent_session_id"].as_str().unwrap(), + ) + }) + .collect(); + + assert_eq!(mappings.len(), 2); + assert_eq!(mappings.get("p_jcode_one"), Some(&"jcode-session-one")); + assert_eq!(mappings.get("p_jcode_two"), Some(&"jcode-session-two")); } #[test] diff --git a/tests/cli/jcode_lifecycle.rs b/tests/cli/jcode_lifecycle.rs new file mode 100644 index 0000000000..8ce1a70798 --- /dev/null +++ b/tests/cli/jcode_lifecycle.rs @@ -0,0 +1,722 @@ +use super::harness::*; +use super::hooks::FakeHookSocket; + +use std::os::unix::fs::PermissionsExt; + +const JCODE_HOOK_ASSET: &str = + include_str!("../../src/integration/assets/jcode/herdr-agent-state.sh"); +const USER_SCALAR: &str = "user-session-observer"; +const USER_FIRST: &str = "user-first"; +const USER_SECOND: &str = "user-second"; +const REPLACEMENT_FIRST: &str = "replacement-first"; +const REPLACEMENT_SECOND: &str = "replacement-second"; +const SCALAR_CONFIG: &str = "# keep user metadata\n[display]\nemoji = false\n\n[hooks]\nturn_end = \"notify\"\nsession_start = \"user-session-observer\"\n"; +const ARRAY_CONFIG: &str = "# keep user metadata\n[display]\nemoji = false\n\n[hooks]\nturn_end = \"notify\"\nsession_start = [\"user-first\", \"user-second\"]\n"; +const MALFORMED_CONFIG: &str = "[hooks]\nsession_start = [\"user-session-observer\", 42]\n"; +const REPLACEMENT_CONFIG: &str = "# user replaced the registered hook list\n[hooks]\nturn_end = \"notify\"\nsession_start = [\"replacement-first\", \"replacement-second\"]\n"; + +/* +The table below is the executable form of this graph. Every edge gets a fresh +Jcode home, materializes and checks its source state, executes the real CLI or +installed hook, then checks the target-state invariants. + +```mermaid +stateDiagram-v2 + AbsentConfig --> ManagedOnly: install + ScalarUserHook --> ManagedPlusScalar: install + ArrayUserHooks --> ManagedPlusArray: install + MalformedConfig --> MalformedConfig: rejected install + ManagedOnly --> ManagedOnly: idempotent reinstall / hook probes + ManagedPlusScalar --> ManagedPlusScalar: idempotent reinstall + ManagedPlusArray --> ManagedPlusArray: idempotent reinstall + ManagedPlusScalar --> UserReplacedManaged: user replaces registration + ManagedOnly --> UninstalledEmpty: uninstall + ManagedPlusScalar --> UninstalledScalar: uninstall + ManagedPlusArray --> UninstalledArray: uninstall + UserReplacedManaged --> UninstalledReplacement: uninstall + UninstalledEmpty --> ManagedOnly: reinstall + UninstalledScalar --> ManagedPlusScalar: reinstall + UninstalledArray --> ManagedPlusArray: reinstall +``` +*/ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum State { + AbsentConfig, + ScalarUserHook, + ArrayUserHooks, + MalformedConfig, + ManagedOnly, + ManagedPlusScalar, + ManagedPlusArray, + UserReplacedManaged, + UninstalledEmpty, + UninstalledScalar, + UninstalledArray, + UninstalledReplacement, +} + +impl State { + fn user_hooks(self) -> &'static [&'static str] { + match self { + Self::ScalarUserHook | Self::ManagedPlusScalar | Self::UninstalledScalar => { + &[USER_SCALAR] + } + Self::ArrayUserHooks | Self::ManagedPlusArray | Self::UninstalledArray => { + &[USER_FIRST, USER_SECOND] + } + Self::UserReplacedManaged | Self::UninstalledReplacement => { + &[REPLACEMENT_FIRST, REPLACEMENT_SECOND] + } + Self::AbsentConfig + | Self::MalformedConfig + | Self::ManagedOnly + | Self::UninstalledEmpty => &[], + } + } + + fn has_config(self) -> bool { + self != Self::AbsentConfig + } + + fn has_managed_hook_file(self) -> bool { + matches!( + self, + Self::ManagedOnly + | Self::ManagedPlusScalar + | Self::ManagedPlusArray + | Self::UserReplacedManaged + ) + } + + fn has_managed_registration(self) -> bool { + matches!( + self, + Self::ManagedOnly | Self::ManagedPlusScalar | Self::ManagedPlusArray + ) + } + + fn preserves_seed_metadata(self) -> bool { + matches!( + self, + Self::ScalarUserHook + | Self::ArrayUserHooks + | Self::ManagedPlusScalar + | Self::ManagedPlusArray + | Self::UninstalledScalar + | Self::UninstalledArray + ) + } +} + +#[derive(Clone, Copy, Debug)] +enum Action { + Install, + IdempotentReinstall, + RejectMalformedInstall, + ReplaceManagedRegistration, + Uninstall, + ReinstallAfterUninstall, + RunOutsideHerdr, + RunCreateAndResume, + RunTwoPanesOnOneSocket, + RunWithoutPython, + RunWithUnavailableSocket, +} + +impl Action { + fn must_not_change_files(self) -> bool { + matches!( + self, + Self::IdempotentReinstall + | Self::RejectMalformedInstall + | Self::RunOutsideHerdr + | Self::RunCreateAndResume + | Self::RunTwoPanesOnOneSocket + | Self::RunWithoutPython + | Self::RunWithUnavailableSocket + ) + } +} + +#[derive(Clone, Copy, Debug)] +struct Edge { + name: &'static str, + from: State, + action: Action, + to: State, +} + +const GRAPH: &[Edge] = &[ + Edge { + name: "install with absent config", + from: State::AbsentConfig, + action: Action::Install, + to: State::ManagedOnly, + }, + Edge { + name: "install with scalar user hook", + from: State::ScalarUserHook, + action: Action::Install, + to: State::ManagedPlusScalar, + }, + Edge { + name: "install with user hook array", + from: State::ArrayUserHooks, + action: Action::Install, + to: State::ManagedPlusArray, + }, + Edge { + name: "reject malformed hook array", + from: State::MalformedConfig, + action: Action::RejectMalformedInstall, + to: State::MalformedConfig, + }, + Edge { + name: "idempotent reinstall without user hooks", + from: State::ManagedOnly, + action: Action::IdempotentReinstall, + to: State::ManagedOnly, + }, + Edge { + name: "idempotent reinstall with scalar user hook", + from: State::ManagedPlusScalar, + action: Action::IdempotentReinstall, + to: State::ManagedPlusScalar, + }, + Edge { + name: "idempotent reinstall with user hook array", + from: State::ManagedPlusArray, + action: Action::IdempotentReinstall, + to: State::ManagedPlusArray, + }, + Edge { + name: "execution outside Herdr reports nothing", + from: State::ManagedOnly, + action: Action::RunOutsideHerdr, + to: State::ManagedOnly, + }, + Edge { + name: "create and resume source mapping", + from: State::ManagedOnly, + action: Action::RunCreateAndResume, + to: State::ManagedOnly, + }, + Edge { + name: "two panes share a socket without sharing session identity", + from: State::ManagedOnly, + action: Action::RunTwoPanesOnOneSocket, + to: State::ManagedOnly, + }, + Edge { + name: "missing Python fails open", + from: State::ManagedOnly, + action: Action::RunWithoutPython, + to: State::ManagedOnly, + }, + Edge { + name: "unavailable socket fails open", + from: State::ManagedOnly, + action: Action::RunWithUnavailableSocket, + to: State::ManagedOnly, + }, + Edge { + name: "user replaces managed registration", + from: State::ManagedPlusScalar, + action: Action::ReplaceManagedRegistration, + to: State::UserReplacedManaged, + }, + Edge { + name: "uninstall after user replacement preserves replacement", + from: State::UserReplacedManaged, + action: Action::Uninstall, + to: State::UninstalledReplacement, + }, + Edge { + name: "uninstall removes only managed hook", + from: State::ManagedOnly, + action: Action::Uninstall, + to: State::UninstalledEmpty, + }, + Edge { + name: "uninstall preserves scalar user hook", + from: State::ManagedPlusScalar, + action: Action::Uninstall, + to: State::UninstalledScalar, + }, + Edge { + name: "uninstall preserves user hook array", + from: State::ManagedPlusArray, + action: Action::Uninstall, + to: State::UninstalledArray, + }, + Edge { + name: "reinstall after empty uninstall", + from: State::UninstalledEmpty, + action: Action::ReinstallAfterUninstall, + to: State::ManagedOnly, + }, + Edge { + name: "reinstall after scalar-hook uninstall", + from: State::UninstalledScalar, + action: Action::ReinstallAfterUninstall, + to: State::ManagedPlusScalar, + }, + Edge { + name: "reinstall after array-hook uninstall", + from: State::UninstalledArray, + action: Action::ReinstallAfterUninstall, + to: State::ManagedPlusArray, + }, +]; + +#[derive(Debug, Eq, PartialEq)] +struct FileSnapshot { + config: Option>, + hook: Option>, +} + +struct LifecycleHarness { + base: PathBuf, + jcode_dir: PathBuf, + config_path: PathBuf, + hooks_dir: PathBuf, + hook_path: PathBuf, +} + +impl LifecycleHarness { + fn new(edge_index: usize) -> Self { + let base = unique_test_dir().join(format!("jcode-lifecycle-edge-{edge_index}")); + let jcode_dir = base.join("jcode-home"); + fs::create_dir_all(&jcode_dir).unwrap(); + let hooks_dir = jcode_dir.join("hooks"); + Self { + config_path: jcode_dir.join("config.toml"), + hook_path: hooks_dir.join("herdr-agent-state.sh"), + base, + jcode_dir, + hooks_dir, + } + } + + fn materialize(&self, state: State, edge: &Edge) { + match state { + State::AbsentConfig => {} + State::ScalarUserHook => self.write_config(SCALAR_CONFIG), + State::ArrayUserHooks => self.write_config(ARRAY_CONFIG), + State::MalformedConfig => self.write_config(MALFORMED_CONFIG), + State::ManagedOnly => self.install(edge), + State::ManagedPlusScalar => { + self.write_config(SCALAR_CONFIG); + self.install(edge); + } + State::ManagedPlusArray => { + self.write_config(ARRAY_CONFIG); + self.install(edge); + } + State::UserReplacedManaged => { + self.write_config(SCALAR_CONFIG); + self.install(edge); + self.write_config(REPLACEMENT_CONFIG); + } + State::UninstalledEmpty => { + self.install(edge); + self.uninstall(edge); + } + State::UninstalledScalar => { + self.write_config(SCALAR_CONFIG); + self.install(edge); + self.uninstall(edge); + } + State::UninstalledArray => { + self.write_config(ARRAY_CONFIG); + self.install(edge); + self.uninstall(edge); + } + State::UninstalledReplacement => { + self.write_config(SCALAR_CONFIG); + self.install(edge); + self.write_config(REPLACEMENT_CONFIG); + self.uninstall(edge); + } + } + } + + fn apply(&self, edge: &Edge) { + let before = self.snapshot(); + match edge.action { + Action::Install | Action::IdempotentReinstall | Action::ReinstallAfterUninstall => { + self.install(edge) + } + Action::RejectMalformedInstall => { + let output = self.integration("install"); + assert!( + !output.status.success(), + "{}: malformed config was accepted", + edge.name + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("must be a string or array of strings"), + "{}: unexpected rejection: {}", + edge.name, + String::from_utf8_lossy(&output.stderr) + ); + } + Action::ReplaceManagedRegistration => self.write_config(REPLACEMENT_CONFIG), + Action::Uninstall => self.uninstall(edge), + Action::RunOutsideHerdr => self.run_outside_herdr(edge), + Action::RunCreateAndResume => self.run_create_and_resume(edge), + Action::RunTwoPanesOnOneSocket => self.run_two_panes(edge), + Action::RunWithoutPython => self.run_without_python(edge), + Action::RunWithUnavailableSocket => self.run_with_unavailable_socket(edge), + } + if edge.action.must_not_change_files() { + assert_eq!( + self.snapshot(), + before, + "{} changed managed files", + edge.name + ); + } + if matches!( + (edge.from, edge.action), + (State::UserReplacedManaged, Action::Uninstall) + ) { + assert_eq!( + fs::read_to_string(&self.config_path).unwrap(), + REPLACEMENT_CONFIG, + "{} rewrote the user's replacement config", + edge.name + ); + } + } + + fn assert_state(&self, state: State, edge: &Edge, side: &str) { + assert_eq!( + self.config_path.is_file(), + state.has_config(), + "{} {side}: config existence does not match {state:?}", + edge.name + ); + assert_eq!( + self.hook_path.is_file(), + state.has_managed_hook_file(), + "{} {side}: hook existence does not match {state:?}", + edge.name + ); + + if state == State::MalformedConfig { + assert_eq!( + fs::read_to_string(&self.config_path).unwrap(), + MALFORMED_CONFIG, + "{} {side}: malformed config was changed", + edge.name + ); + assert!( + !self.hooks_dir.exists(), + "{} {side}: rejected config left managed filesystem state", + edge.name + ); + return; + } + + if state.has_config() { + let content = fs::read_to_string(&self.config_path).unwrap(); + let mut expected: Vec<_> = state + .user_hooks() + .iter() + .map(|hook| (*hook).to_string()) + .collect(); + if state.has_managed_registration() { + expected.push(self.managed_command()); + } + assert_eq!( + session_start_commands(&content), + expected, + "{} {side}: session_start commands do not match {state:?}", + edge.name + ); + if state.preserves_seed_metadata() { + assert!( + content.contains("# keep user metadata"), + "{} {side}", + edge.name + ); + assert!( + content.contains("turn_end = \"notify\""), + "{} {side}", + edge.name + ); + assert!(content.contains("emoji = false"), "{} {side}", edge.name); + } + } + + if state.has_managed_hook_file() { + assert_eq!( + fs::read_to_string(&self.hook_path).unwrap(), + JCODE_HOOK_ASSET, + "{} {side}: installed hook differs from the bundled asset", + edge.name + ); + let mode = fs::metadata(&self.hook_path).unwrap().permissions().mode(); + assert_ne!( + mode & 0o111, + 0, + "{} {side}: hook is not executable", + edge.name + ); + } + } + + fn integration(&self, action: &str) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", action, "jcode"]) + .env("JCODE_HOME", &self.jcode_dir) + .env_remove("HERDR_ENV") + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .output() + .unwrap() + } + + fn install(&self, edge: &Edge) { + self.assert_cli_success(edge, "install", self.integration("install")); + } + + fn uninstall(&self, edge: &Edge) { + self.assert_cli_success(edge, "uninstall", self.integration("uninstall")); + } + + fn assert_cli_success(&self, edge: &Edge, action: &str, output: std::process::Output) { + assert!( + output.status.success(), + "{}: {action} failed: stdout={} stderr={}", + edge.name, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn write_config(&self, content: &str) { + fs::write(&self.config_path, content).unwrap(); + } + + fn managed_command(&self) -> String { + format!("bash '{}'", self.hook_path.display()) + } + + fn snapshot(&self) -> FileSnapshot { + FileSnapshot { + config: fs::read(&self.config_path).ok(), + hook: fs::read(&self.hook_path).ok(), + } + } + + fn invoke_hook( + &self, + socket_path: &Path, + pane_id: &str, + session_id: &str, + source: Option<&str>, + inside_herdr: bool, + path: Option<&Path>, + ) -> std::process::Output { + let mut command = Command::new(&self.hook_path); + command + .env_remove("HERDR_ENV") + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_PANE_ID") + .env_remove("JCODE_HOOK_SESSION_ID") + .env_remove("JCODE_HOOK_SOURCE") + .env("HERDR_SOCKET_PATH", socket_path) + .env("HERDR_PANE_ID", pane_id) + .env("JCODE_HOOK_SESSION_ID", session_id); + if inside_herdr { + command.env("HERDR_ENV", "1"); + } + if let Some(source) = source { + command.env("JCODE_HOOK_SOURCE", source); + } + if let Some(path) = path { + command.env("PATH", path); + } + command.output().unwrap() + } + + fn assert_hook_success(&self, edge: &Edge, output: &std::process::Output) { + assert!( + output.status.success(), + "{}: hook failed open contract: stdout={} stderr={}", + edge.name, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn run_outside_herdr(&self, edge: &Edge) { + let socket = FakeHookSocket::start(1); + let socket_path = socket.path().to_path_buf(); + let output = self.invoke_hook( + &socket_path, + "p_outside", + "outside-session", + Some("create"), + false, + None, + ); + self.assert_hook_success(edge, &output); + assert!( + socket.finish().is_empty(), + "{}: hook reported outside Herdr", + edge.name + ); + } + + fn run_create_and_resume(&self, edge: &Edge) { + let socket = FakeHookSocket::start(2); + let socket_path = socket.path().to_path_buf(); + for (pane, session, source) in [ + ("p_create", "create-session", "create"), + ("p_resume", "resume-session", "resume"), + ] { + let output = self.invoke_hook(&socket_path, pane, session, Some(source), true, None); + self.assert_hook_success(edge, &output); + } + let requests = socket.finish(); + assert_eq!(requests.len(), 2, "{}: missing reports", edge.name); + assert_report( + edge, + &requests[0], + "p_create", + "create-session", + Some("startup"), + ); + assert_report( + edge, + &requests[1], + "p_resume", + "resume-session", + Some("resume"), + ); + } + + fn run_two_panes(&self, edge: &Edge) { + let socket = FakeHookSocket::start(2); + let socket_path = socket.path().to_path_buf(); + for (pane, session) in [ + ("p_shared_one", "session-one"), + ("p_shared_two", "session-two"), + ] { + let output = self.invoke_hook(&socket_path, pane, session, None, true, None); + self.assert_hook_success(edge, &output); + } + let requests = socket.finish(); + assert_eq!(requests.len(), 2, "{}: missing reports", edge.name); + assert_report(edge, &requests[0], "p_shared_one", "session-one", None); + assert_report(edge, &requests[1], "p_shared_two", "session-two", None); + } + + fn run_without_python(&self, edge: &Edge) { + let empty_path = self.base.join("empty-path"); + fs::create_dir_all(&empty_path).unwrap(); + let socket = FakeHookSocket::start(1); + let socket_path = socket.path().to_path_buf(); + let output = self.invoke_hook( + &socket_path, + "p_no_python", + "no-python-session", + Some("create"), + true, + Some(&empty_path), + ); + self.assert_hook_success(edge, &output); + assert!( + socket.finish().is_empty(), + "{}: hook reported without Python", + edge.name + ); + } + + fn run_with_unavailable_socket(&self, edge: &Edge) { + let socket_path = self.base.join("unavailable.sock"); + let output = self.invoke_hook( + &socket_path, + "p_no_socket", + "no-socket-session", + Some("resume"), + true, + None, + ); + self.assert_hook_success(edge, &output); + } +} + +impl Drop for LifecycleHarness { + fn drop(&mut self) { + cleanup_test_base(&self.base); + } +} + +fn session_start_commands(content: &str) -> Vec { + let document: toml::Value = toml::from_str(content).unwrap(); + let Some(value) = document + .get("hooks") + .and_then(|hooks| hooks.get("session_start")) + else { + return Vec::new(); + }; + match value { + toml::Value::String(command) => vec![command.clone()], + toml::Value::Array(commands) => commands + .iter() + .map(|command| command.as_str().unwrap().to_string()) + .collect(), + other => panic!("unexpected session_start value: {other:?}"), + } +} + +fn assert_report( + edge: &Edge, + request: &serde_json::Value, + pane_id: &str, + session_id: &str, + start_source: Option<&str>, +) { + assert_eq!( + request["method"], "pane.report_agent_session", + "{}", + edge.name + ); + assert_eq!(request["params"]["source"], "herdr:jcode", "{}", edge.name); + assert_eq!(request["params"]["agent"], "jcode", "{}", edge.name); + assert_eq!(request["params"]["pane_id"], pane_id, "{}", edge.name); + assert_eq!( + request["params"]["agent_session_id"], session_id, + "{}", + edge.name + ); + match start_source { + Some(source) => assert_eq!( + request["params"]["session_start_source"], source, + "{}", + edge.name + ), + None => assert!( + request["params"].get("session_start_source").is_none(), + "{}", + edge.name + ), + } + assert!(request["params"].get("state").is_none(), "{}", edge.name); +} + +#[test] +fn jcode_lifecycle_state_space_graph() { + for (edge_index, edge) in GRAPH.iter().enumerate() { + let harness = LifecycleHarness::new(edge_index); + harness.materialize(edge.from, edge); + harness.assert_state(edge.from, edge, "source"); + harness.apply(edge); + harness.assert_state(edge.to, edge, "target"); + } +} diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs index 0fdff6f1f9..164a882d37 100644 --- a/tests/cli/mod.rs +++ b/tests/cli/mod.rs @@ -4,6 +4,7 @@ mod agent_wait; mod agents; mod harness; mod hooks; +mod jcode_lifecycle; mod panes; mod plugins; mod protocol; diff --git a/website/agent-detection/index.toml b/website/agent-detection/index.toml index fba996ea39..447f6b547c 100644 --- a/website/agent-detection/index.toml +++ b/website/agent-detection/index.toml @@ -40,6 +40,10 @@ path = "gemini.toml" id = "grok" path = "grok.toml" +[[agents]] +id = "jcode" +path = "jcode.toml" + [[agents]] id = "hermes" path = "hermes.toml" diff --git a/website/agent-detection/jcode.toml b/website/agent-detection/jcode.toml new file mode 100644 index 0000000000..c2aa1c9595 --- /dev/null +++ b/website/agent-detection/jcode.toml @@ -0,0 +1,47 @@ +id = "jcode" +version = "2026.08.03.1" +min_engine_version = 1 +updated_at = "2026-08-03T00:00:00Z" +aliases = ["j-code", "herdr:jcode"] + +# Jcode pins its composer to the bottom of the pane. The next message number is +# followed by one marker: `>` for ready, `…` while a turn is in flight, `»` for +# an armed skill, or `$` for shell mode. Only `…` means working. +[[rules]] +id = "composer_processing" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(4)" +visible_working = true +line_regex = ['^\s*\d+…'] + +# A multi-line composer can move its marker outside the recent-line window. +# Jcode's processing line then shows either a braille spinner or an animated +# three-dot tool bar directly above the composer. +[[rules]] +id = "status_line_spinner_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(4)" +visible_working = true +line_regex = ['^[\x{2800}-\x{28FF}] \S'] + +[[rules]] +id = "status_line_tool_bar_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(4)" +visible_working = true +line_regex = ['^[·●]{3} \S+ [·●]{3}(\s|$)'] + +[[rules]] +id = "composer_ready" +state = "idle" +priority = 850 +region = "bottom_non_empty_lines(4)" +visible_idle = true +line_regex = ['^\s*\d+[>»$]'] + +# Jcode emits no state-bearing OSC progress/title sequence. It also has no +# in-session approval prompt, so there is deliberately no blocked rule. Shell +# mode and user-opened overlays correctly remain idle.