aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-18 23:46:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-18 23:56:20 +0300
commita51a15914a5b29155022a1fd589d2bcc6409cadf (patch)
tree8602b6d94c811666d49e4c2aa7636bb6a9b0e8a3 /src
parent60c040057e3949ae93ee88d495df09b4b848d22d (diff)
fix: support javascript integrations on windows
refs #1531
Diffstat (limited to 'src')
-rw-r--r--src/integration/assets/herdr-agent-state.test.ts78
-rw-r--r--src/integration/assets/kilo/herdr-agent-state.js7
-rw-r--r--src/integration/assets/omp/herdr-agent-state.ts8
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js5
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts8
-rw-r--r--src/integration/mod.rs6
-rw-r--r--src/integration/registry.rs6
-rw-r--r--src/integration/targets.rs38
-rw-r--r--src/integration/tests.rs45
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();