diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-18 23:46:34 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-18 23:56:20 +0300 |
| commit | a51a15914a5b29155022a1fd589d2bcc6409cadf (patch) | |
| tree | 8602b6d94c811666d49e4c2aa7636bb6a9b0e8a3 /src/integration | |
| parent | 60c040057e3949ae93ee88d495df09b4b848d22d (diff) | |
fix: support javascript integrations on windows
refs #1531
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/herdr-agent-state.test.ts | 78 | ||||
| -rw-r--r-- | src/integration/assets/kilo/herdr-agent-state.js | 7 | ||||
| -rw-r--r-- | src/integration/assets/omp/herdr-agent-state.ts | 8 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 5 | ||||
| -rw-r--r-- | src/integration/assets/pi/herdr-agent-state.ts | 8 | ||||
| -rw-r--r-- | src/integration/mod.rs | 6 | ||||
| -rw-r--r-- | src/integration/registry.rs | 6 | ||||
| -rw-r--r-- | src/integration/targets.rs | 38 | ||||
| -rw-r--r-- | src/integration/tests.rs | 45 |
9 files changed, 152 insertions, 49 deletions
diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts index c662e132..50f1fc52 100644 --- a/src/integration/assets/herdr-agent-state.test.ts +++ b/src/integration/assets/herdr-agent-state.test.ts @@ -1,9 +1,11 @@ import { afterEach, expect, test } from "bun:test"; import { rm } from "node:fs/promises"; -import { createServer, type Server } from "node:net"; +import net, { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; +const originalPlatform = process.platform; +const originalCreateConnection = net.createConnection; const originalEnvironment = { HERDR_ENV: process.env.HERDR_ENV, HERDR_OMP_IDLE_DEBOUNCE_MS: process.env.HERDR_OMP_IDLE_DEBOUNCE_MS, @@ -30,6 +32,8 @@ afterEach(async () => { socketPath = undefined; } + Object.defineProperty(process, "platform", { value: originalPlatform }); + net.createConnection = originalCreateConnection; for (const [name, value] of Object.entries(originalEnvironment)) { if (value === undefined) { delete process.env[name]; @@ -44,6 +48,15 @@ const integrations = [ { name: "Oh My Pi", modulePath: "./omp/herdr-agent-state.ts" }, ] as const; +const socketPlugins = [ + { + name: "OpenCode", + modulePath: "./opencode/herdr-agent-state.js", + sessionID: "opencode-session", + }, + { name: "Kilo", modulePath: "./kilo/herdr-agent-state.js", sessionID: "kilo-session" }, +] as const; + function importFresh(modulePath: string) { importCounter += 1; return import(`${modulePath}?test=${importCounter}`); @@ -74,6 +87,15 @@ function configureIntegrationEnvironment(recordingSocketPath: string) { process.env.HERDR_PANE_ID = "test:p1"; } +function captureConnectionEndpoint() { + let connectedEndpoint: unknown; + net.createConnection = ((...args: unknown[]) => { + connectedEndpoint = args[0]; + return Reflect.apply(originalCreateConnection, net, args); + }) as typeof net.createConnection; + return () => connectedEndpoint; +} + async function startRecordingServer(name: string): Promise<unknown[]> { const recordingSocketPath = join(tmpdir(), `herdr-${name}-${process.pid}.sock`); socketPath = recordingSocketPath; @@ -102,7 +124,61 @@ async function startRecordingServer(name: string): Promise<unknown[]> { return requests; } +for (const socketPlugin of socketPlugins) { + test(`${socketPlugin.name} maps the Windows socket marker path to a named pipe endpoint`, async () => { + const markerPath = `herdr-${socketPlugin.name.toLowerCase()}-${process.pid}.sock`; + configureIntegrationEnvironment(markerPath); + Object.defineProperty(process, "platform", { value: "win32" }); + const connectedEndpoint = captureConnectionEndpoint(); + + const { HerdrAgentStatePlugin } = await importFresh(socketPlugin.modulePath); + const plugin = await HerdrAgentStatePlugin(); + await plugin.event({ + event: { + type: "session.updated", + properties: { sessionID: socketPlugin.sessionID }, + }, + }); + + expect(connectedEndpoint()).toBe(`\\\\.\\pipe\\${markerPath}`); + }); +} + +test("OpenCode stays disabled without the Herdr socket environment", async () => { + process.env.HERDR_ENV = "1"; + process.env.HERDR_PANE_ID = "test:p1"; + delete process.env.HERDR_SOCKET_PATH; + + const { HerdrAgentStatePlugin } = await importFresh("./opencode/herdr-agent-state.js"); + + expect(await HerdrAgentStatePlugin()).toEqual({}); +}); + for (const integration of integrations) { + test(`${integration.name} maps the Windows socket marker path to a named pipe endpoint`, async () => { + const markerPath = `herdr-${integration.name.toLowerCase().replaceAll(" ", "-")}-${process.pid}.sock`; + configureIntegrationEnvironment(markerPath); + Object.defineProperty(process, "platform", { value: "win32" }); + const connectedEndpoint = captureConnectionEndpoint(); + const { handlers, pi } = createExtensionHarness(); + + const { default: install } = await importFresh(integration.modulePath); + install(pi); + await handlers.get("session_start")?.( + { reason: "startup" }, + { + hasUI: true, + isIdle: () => true, + sessionManager: { + getSessionFile: () => undefined, + getSessionId: () => "test-session", + }, + }, + ); + + expect(connectedEndpoint()).toBe(`\\\\.\\pipe\\${markerPath}`); + }); + test(`${integration.name} reload preserves working state when the agent is active`, async () => { const requests = await startRecordingServer( integration.name.toLowerCase().replaceAll(" ", "-"), diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/src/integration/assets/kilo/herdr-agent-state.js index fc12919c..e8600ab1 100644 --- a/src/integration/assets/kilo/herdr-agent-state.js +++ b/src/integration/assets/kilo/herdr-agent-state.js @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=kilo -// HERDR_INTEGRATION_VERSION=2 +// HERDR_INTEGRATION_VERSION=3 import net from "node:net"; @@ -48,6 +48,9 @@ function request(method, params) { return Promise.resolve(); } + const socketEndpoint = + process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath; + const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) .toString() .padStart(6, "0")}`; @@ -64,7 +67,7 @@ function request(method, params) { }; return new Promise((resolve) => { - const client = net.createConnection(socketPath, () => { + const client = net.createConnection(socketEndpoint, () => { client.write(`${JSON.stringify(request)}\n`); }); diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/src/integration/assets/omp/herdr-agent-state.ts index 9bb6671a..350cdea7 100644 --- a/src/integration/assets/omp/herdr-agent-state.ts +++ b/src/integration/assets/omp/herdr-agent-state.ts @@ -2,13 +2,15 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=omp -// HERDR_INTEGRATION_VERSION=5 +// HERDR_INTEGRATION_VERSION=6 // @ts-nocheck -import { createConnection } from "node:net"; +import net from "node:net"; const HERDR_ENV = process.env.HERDR_ENV; const socketPath = process.env.HERDR_SOCKET_PATH; +const socketEndpoint = + process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; const paneId = process.env.HERDR_PANE_ID; const source = "herdr:omp"; @@ -36,7 +38,7 @@ function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolea resolve(delivered); }; - const socket = createConnection(socketPath!); + const socket = net.createConnection(socketEndpoint!); socket.on("error", () => finish(false)); socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); socket.on("data", () => finish(true)); diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 4d59f9ec..27ea7ee5 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -62,6 +62,9 @@ function requestOnce(method, params) { return Promise.resolve(); } + const socketEndpoint = + process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath; + const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) .toString() .padStart(6, "0")}`; @@ -78,7 +81,7 @@ function requestOnce(method, params) { }; return new Promise((resolve) => { - const client = net.createConnection(socketPath, () => { + const client = net.createConnection(socketEndpoint, () => { client.write(`${JSON.stringify(request)}\n`); }); diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index 52340ebb..218cede9 100644 --- a/src/integration/assets/pi/herdr-agent-state.ts +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -2,13 +2,15 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=pi -// HERDR_INTEGRATION_VERSION=5 +// HERDR_INTEGRATION_VERSION=6 // @ts-nocheck -import { createConnection } from "node:net"; +import net from "node:net"; const HERDR_ENV = process.env.HERDR_ENV; const socketPath = process.env.HERDR_SOCKET_PATH; +const socketEndpoint = + process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; const paneId = process.env.HERDR_PANE_ID; const source = "herdr:pi"; @@ -34,7 +36,7 @@ function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolea resolve(delivered); }; - const socket = createConnection(socketPath!); + const socket = net.createConnection(socketEndpoint!); socket.on("error", () => finish(false)); socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); socket.on("data", () => finish(true)); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 66c62d7a..fdc1c5bd 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -22,10 +22,10 @@ pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, Integration const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); -const PI_INTEGRATION_VERSION: u32 = 5; +const PI_INTEGRATION_VERSION: u32 = 6; const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts"); -const OMP_INTEGRATION_VERSION: u32 = 5; +const OMP_INTEGRATION_VERSION: u32 = 6; const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -143,7 +143,7 @@ const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-st const OPENCODE_INTEGRATION_VERSION: u32 = 9; const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); -const KILO_INTEGRATION_VERSION: u32 = 2; +const KILO_INTEGRATION_VERSION: u32 = 3; const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 84e42a25..bcd17220 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -61,9 +61,13 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati { matches!( target, - crate::api::schema::IntegrationTarget::Claude + crate::api::schema::IntegrationTarget::Pi + | crate::api::schema::IntegrationTarget::Omp + | crate::api::schema::IntegrationTarget::Claude | crate::api::schema::IntegrationTarget::Codex | crate::api::schema::IntegrationTarget::Copilot + | crate::api::schema::IntegrationTarget::Opencode + | crate::api::schema::IntegrationTarget::Kilo | crate::api::schema::IntegrationTarget::Droid | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 2b4c5615..99c150a3 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -45,14 +45,22 @@ use super::{ QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, }; +fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { + if dir.is_dir() { + return Ok(()); + } + if dir.parent().is_some_and(|parent| parent.is_dir()) { + return fs::create_dir_all(dir); + } + Err(io::Error::other(format!( + "{agent} extension directory not found at {}. install {agent} first", + dir.display() + ))) +} + pub(crate) fn install_pi() -> io::Result<PathBuf> { let dir = pi_extension_dir()?; - if !dir.is_dir() { - return Err(io::Error::other(format!( - "pi extension directory not found at {}. install pi and create the extensions directory first", - dir.display() - ))); - } + ensure_extension_dir(&dir, "pi")?; let path = dir.join(PI_EXTENSION_INSTALL_NAME); fs::write(&path, PI_EXTENSION_ASSET)?; @@ -61,23 +69,7 @@ pub(crate) fn install_pi() -> io::Result<PathBuf> { pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> { let dir = omp_extension_dir()?; - if !dir.is_dir() { - if dir.parent().is_some_and(|parent| parent.is_dir()) { - fs::create_dir_all(&dir)?; - } else { - return Err(io::Error::other(format!( - "omp extension directory not found at {}. install omp and create the extensions directory first", - dir.display() - ))); - } - } - - if !dir.is_dir() { - return Err(io::Error::other(format!( - "omp extension directory not found at {}. install omp and create the extensions directory first", - dir.display() - ))); - } + ensure_extension_dir(&dir, "omp")?; let removed_legacy_pi_extension = remove_legacy_pi_extension_from_omp_dir(&dir)?; let extension_path = dir.join(OMP_EXTENSION_INSTALL_NAME); diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 3d578c53..41714ffa 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -165,21 +165,21 @@ fn home_dir_uses_userprofile_when_home_is_missing() { #[cfg(windows)] #[test] -fn windows_supports_only_cli_hook_integrations() { +fn windows_supports_portable_integrations() { use crate::api::schema::IntegrationTarget; - assert!(!integration_target_supported(IntegrationTarget::Pi)); - assert!(!integration_target_supported(IntegrationTarget::Omp)); - assert!(!integration_target_supported(IntegrationTarget::Opencode)); - assert!(!integration_target_supported(IntegrationTarget::Kilo)); assert!(!integration_target_supported(IntegrationTarget::Hermes)); assert!(!integration_target_supported(IntegrationTarget::Cursor)); assert!(!integration_target_supported(IntegrationTarget::Devin)); assert!(!integration_target_supported(IntegrationTarget::Mastracode)); + assert!(integration_target_supported(IntegrationTarget::Pi)); + assert!(integration_target_supported(IntegrationTarget::Omp)); assert!(integration_target_supported(IntegrationTarget::Claude)); assert!(integration_target_supported(IntegrationTarget::Codex)); assert!(integration_target_supported(IntegrationTarget::Copilot)); + assert!(integration_target_supported(IntegrationTarget::Opencode)); + assert!(integration_target_supported(IntegrationTarget::Kilo)); assert!(integration_target_supported(IntegrationTarget::Droid)); assert!(integration_target_supported(IntegrationTarget::Kimi)); assert!(integration_target_supported(IntegrationTarget::Qodercli)); @@ -187,7 +187,7 @@ fn windows_supports_only_cli_hook_integrations() { #[cfg(windows)] #[test] -fn windows_does_not_offer_unsupported_integrations_even_when_commands_exist() { +fn windows_availability_excludes_unsupported_integrations() { use crate::api::schema::IntegrationTarget; let _lock = integration_env_lock(); @@ -206,10 +206,10 @@ fn windows_does_not_offer_unsupported_integrations_even_when_commands_exist() { fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("mastracode.cmd"), "@echo off\r\n").unwrap(); - assert!(!integration_target_available(IntegrationTarget::Pi)); - assert!(!integration_target_available(IntegrationTarget::Omp)); - assert!(!integration_target_available(IntegrationTarget::Opencode)); - assert!(!integration_target_available(IntegrationTarget::Kilo)); + assert!(integration_target_available(IntegrationTarget::Pi)); + assert!(integration_target_available(IntegrationTarget::Omp)); + assert!(integration_target_available(IntegrationTarget::Opencode)); + assert!(integration_target_available(IntegrationTarget::Kilo)); assert!(!integration_target_available(IntegrationTarget::Hermes)); assert!(!integration_target_available(IntegrationTarget::Cursor)); assert!(!integration_target_available(IntegrationTarget::Devin)); @@ -238,10 +238,10 @@ fn windows_install_rejects_unsupported_integration_before_config_lookup() { std::env::remove_var("HOMEDRIVE"); std::env::remove_var("HOMEPATH"); - let err = install_target(IntegrationTarget::Pi).unwrap_err(); + let err = install_target(IntegrationTarget::Hermes).unwrap_err(); assert_eq!( err.to_string(), - "pi integration is not supported on Windows" + "hermes integration is not supported on Windows" ); if let Some(home) = original_home { @@ -487,6 +487,27 @@ fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { } #[test] +fn install_pi_creates_extensions_dir_when_agent_dir_exists() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let agent_dir = home.join(".pi/agent"); + fs::create_dir_all(&agent_dir).unwrap(); + std::env::set_var("HOME", &home); + + let path = install_pi().unwrap(); + + assert_eq!( + path, + agent_dir.join("extensions").join(PI_EXTENSION_INSTALL_NAME) + ); + assert!(path.is_file()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[test] fn install_pi_uses_pi_coding_agent_dir_env() { let _lock = integration_env_lock(); let base = unique_base(); |
