/** * HSP for Pi — thin control surface for Relay (settle chatter). * * Commands only — no agent_end / agent_settled sounds (avoids double chatter * when `hsp run` already listens to machine load). * * /hsp setup | status | run | stop | ping | help */ import { spawn, execFileSync } from "node:child_process"; import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { hspLogPath, hspPidPath, hspStateDir, resolveHspBin, } from "./lib/hsp-bin.ts"; const HSP_REPO = "https://github.com/maximilianwruhs-cyber/HSP"; const INSTALL_HINT = [ "Build + install binary:", " curl -fsSL https://raw.githubusercontent.com/maximilianwruhs-cyber/hsp-pi/main/scripts/install-hsp.sh | bash", "Or from a local HSP clone:", " cargo build --release --manifest-path ~/github-clone/HSP/hsp-rs/Cargo.toml", " install -m 755 ~/github-clone/HSP/hsp-rs/target/release/hsp ~/.local/bin/hsp", ].join("\n"); function runHsp(bin: string, args: string[], opts?: { timeout?: number }): string { try { return execFileSync(bin, args, { encoding: "utf8", timeout: opts?.timeout ?? 30_000, env: process.env, }).trim(); } catch (err) { const e = err as { stdout?: string; stderr?: string; message?: string }; return (e.stderr || e.stdout || e.message || String(err)).trim(); } } function readPid(): number | null { const path = hspPidPath(); if (!existsSync(path)) return null; const raw = readFileSync(path, "utf8").trim(); const pid = Number(raw); if (!Number.isFinite(pid) || pid <= 0) return null; try { process.kill(pid, 0); return pid; } catch { try { unlinkSync(path); } catch { /* ignore */ } return null; } } function isHspRunProcess(pid: number): boolean { try { const cmd = readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " "); return /\bhsp\b/.test(cmd) && /\brun\b/.test(cmd); } catch { return false; } } function statusReport(): string { const lines: string[] = ["HSP × Pi status", ""]; const bin = resolveHspBin(); if (!bin) { lines.push("binary: MISSING"); lines.push(INSTALL_HINT); } else { lines.push(`binary: ${bin}`); const help = runHsp(bin, ["--help"], { timeout: 5_000 }); const first = help.split("\n").find((l) => l.trim().length > 0) || "ok"; lines.push(` ${first}`); } const pid = readPid(); if (pid && isHspRunProcess(pid)) { lines.push(`sidecar: running (pid ${pid})`); lines.push(` log: ${hspLogPath()}`); } else if (pid) { lines.push(`sidecar: stale pid ${pid} (not an hsp run) — /hsp stop to clear`); } else { lines.push("sidecar: stopped"); } lines.push(""); lines.push("Commands: /hsp setup | status | run | stop | ping | help"); lines.push("No Pi lifecycle sounds — load-based settle only (one voice)."); lines.push(`Engine: ${HSP_REPO}`); return lines.join("\n"); } function startRun(bin: string): string { const existing = readPid(); if (existing && isHspRunProcess(existing)) { return `already running (pid ${existing})\nlog: ${hspLogPath()}`; } mkdirSync(hspStateDir(), { recursive: true }); const logPath = hspLogPath(); const logFd = openSync(logPath, "a"); const child = spawn(bin, ["run"], { detached: true, stdio: ["ignore", logFd, logFd], env: process.env, }); child.unref(); if (child.pid == null) { return "failed to start hsp run (no pid)"; } writeFileSync(hspPidPath(), String(child.pid)); return [ `started hsp run (pid ${child.pid})`, "silent while the machine works → one Relay phrase on settle", `log: ${logPath}`, ].join("\n"); } function stopRun(): string { const pid = readPid(); if (!pid) return "sidecar already stopped"; if (!isHspRunProcess(pid)) { try { unlinkSync(hspPidPath()); } catch { /* ignore */ } return `cleared stale pid ${pid}`; } try { process.kill(pid, "SIGTERM"); } catch (err) { return `could not signal pid ${pid}: ${err}`; } try { unlinkSync(hspPidPath()); } catch { /* ignore */ } return `stopped hsp run (pid ${pid})`; } function helpText(): string { return [ "HSP Relay for Pi", "", " /hsp setup — check binary; print install hints", " /hsp status — binary + sidecar pid", " /hsp run — start silent load sidecar", " /hsp stop — stop sidecar", " /hsp ping — play a fixed sample phrase (speaker check)", " /hsp help — this text", "", "Does not chatter on Pi agent_end — that would double the settle voice.", ].join("\n"); } export default function hspPi(pi: ExtensionAPI) { let promptedMissing = false; pi.registerCommand("hsp", { description: "HSP Relay — settle chatter sidecar (setup / run / stop / ping)", handler: async (args, ctx) => { const sub = (args || "").trim().split(/\s+/)[0]?.toLowerCase() || "status"; if (sub === "help" || sub === "?") { ctx.ui.notify(helpText(), "info"); return; } if (sub === "setup" || sub === "install") { const bin = resolveHspBin(); if (!bin) { ctx.ui.notify(["hsp binary not found.", "", INSTALL_HINT].join("\n"), "error"); return; } ctx.ui.notify( [ `binary ok: ${bin}`, "", "Start the sidecar: /hsp run", "Speaker check: /hsp ping", "Status anytime: /hsp status", ].join("\n"), "info", ); return; } if (sub === "run" || sub === "start") { const bin = resolveHspBin(); if (!bin) { ctx.ui.notify(["hsp binary not found.", "", INSTALL_HINT].join("\n"), "error"); return; } ctx.ui.notify(startRun(bin), "info"); return; } if (sub === "stop" || sub === "kill") { ctx.ui.notify(stopRun(), "info"); return; } if (sub === "ping") { const bin = resolveHspBin(); if (!bin) { ctx.ui.notify(["hsp binary not found.", "", INSTALL_HINT].join("\n"), "error"); return; } const out = runHsp(bin, ["ping"], { timeout: 15_000 }); ctx.ui.notify(out || "ping done", "info"); return; } // default: status ctx.ui.notify(statusReport(), "info"); }, }); pi.on("session_start", async (_event, ctx) => { if (promptedMissing) return; if (resolveHspBin()) return; promptedMissing = true; ctx.ui.notify( ["HSP binary not found for Pi.", INSTALL_HINT, "", "Then: /hsp setup"].join("\n"), "info", ); }); }