From 4938de2d8b0fb6aaa540f7b93dbc63373d99d123 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 01/13] Windows machines: herdr-core over Git Bash, and herdr-push pair there Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++ .../makefunstuff/herdr/core/hc/HcDetection.kt | 38 ++++++++++- .../makefunstuff/herdr/core/HerdrCoreTest.kt | 65 +++++++++++++++++++ .../com/makefunstuff/herdr/core/PushTest.kt | 2 +- host/herdr-push/README.md | 2 + host/herdr-push/herdr-push | 65 ++++++++++++++++--- 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 57bee462..bc000059 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,14 @@ How it works and what it touches: [`host/herdr-push/README.md`](host/herdr-push/ 2. Add it to `~/.ssh/authorized_keys` on the machine running herdr. 3. In **Machines**, tap **Add machine**, then **Type the address instead**, and enter `user@host[:port]`. The app pins the host key on first connect. +**A Windows machine** works with the Herdr desktop app's herdr-core (the Agents, chat and Terminal screens; classic herdr, notifications and the health screen need Linux or macOS). It needs OpenSSH Server running with [Git for Windows](https://gitforwindows.org)' Bash as its shell, once, in a PowerShell opened as administrator: + +```powershell +New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -Value 'C:\Program Files\Git\bin\bash.exe' -PropertyType String -Force +``` + +Then pair as above with `python herdr-push pair`. For a user in Administrators, Windows' sshd reads keys from `C:\ProgramData\ssh\administrators_authorized_keys`, so run `pair` in a terminal opened as administrator too (or add the key there by hand). + Machines reachable only through another one can use it as a jump host ("Connect through"). For zero-touch or read-only machines, **Allow host scripts** (on by default) can be turned off so the app skips copying its helper scripts (`herdr-usage`, `herdr-logins`, `herdr-push`) there. From a machine's menu, **Import herdr machines** adds the machines its herdr already knows (`herdr machine list`). ## How it talks to herdr diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt index d7c15a12..c80e40d7 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt @@ -19,6 +19,12 @@ data class HcProbe( val coreExes: List = emptyList(), /** The other sessions whose core runs , by name. */ val others: List = emptyList(), + /** + * A Windows machine (OpenSSH with Git Bash as its shell): there the core listens where no `sh` + * can see it, so there is no [socket]; `hc server status` says whether it runs, and `connect` + * finds it the way hc does ([HcDetection.windowsConnectCommand]). + */ + val windows: Boolean = false, ) { /** * The executable to run `connect` with: a running core's own build that is also installed where @@ -93,8 +99,22 @@ object HcDetection { */ val probe: String get() = "sh -c ${quote(PROBE)} hc" + /** + * Windows (Git Bash, MSYS or Cygwin under OpenSSH): `windows`, then, when the default session's + * core answers `server status`, `coreexepid` and `binexe--versioncore` + * for the herdr-core on PATH, else the desktop app's own install. Other sessions aren't looked for. + */ + private const val WINDOWS = "case \"\$(uname -s 2>/dev/null)\" in MINGW*|MSYS*|CYGWIN*) " + + "echo windows; b=\"\$(command -v herdr-core 2>/dev/null)\"; " + + "if [ -z \"\$b\" ] && [ -n \"\$LOCALAPPDATA\" ]; then b=\"\$(cygpath -u \"\$LOCALAPPDATA\" 2>/dev/null)/Programs/herdr-core/herdr-core\"; fi; " + + "[ -n \"\$b\" ] && [ -f \"\$b\" ] || exit 0; " + + "j=\"\$(\"\$b\" server status --json /dev/null)\" || exit 0; " + + "p=\"\$(printf '%s' \"\$j\" | sed -n 's/.*\"pid\":\\([0-9][0-9]*\\).*/\\1/p' | head -n 1)\"; [ -n \"\$p\" ] || exit 0; " + + "printf 'core\\t%s\\t%s\\n' \"\$b\" \"\$p\"; " + + "printf 'bin\\t%s\\t%s\\tcore\\n' \"\$b\" \"\$(\"\$b\" --version /dev/null | head -n 1)\"; exit 0;; esac; " + private val PROBE: String - get() = SOCKET + + get() = WINDOWS + SOCKET + "seen=' '; found=''; " + "emit() { [ -n \"\$2\" ] && [ -f \"\$2\" ] && [ -x \"\$2\" ] || return 0; " + "k=\"\$1:\$2\"; case \"\$seen\" in *\" \$k \"*) return 0;; esac; seen=\"\$seen\$k \"; found=1; " + @@ -162,9 +182,20 @@ object HcDetection { val coreRows = rows.count { it.size >= 2 && it[0] == "core" } // The session's lock held (Linux), else some core process (pgrep), else the socket alone. val locked = rows.any { it.size >= 2 && it[0] == "lock" } + if (rows.any { it.singleOrNull() == "windows" }) { + // Windows: the core answered `server status`, which is all there is to know. + return HcProbe(null, running = coreRows > 0 && binaries.isNotEmpty(), binaries = binaries.distinctBy { it.path to it.source }, coreExes = cores, windows = true) + } return HcProbe(socket, running = socket != null && (locked || coreRows > 0 || noPgrep), binaries = binaries.distinctBy { it.path to it.source }, coreExes = cores, others = others) } + /** + * ` connect` on Windows, where hc finds the default session's core itself; refused when + * `server status` says none runs, so `connect` never starts one there either. + */ + fun windowsConnectCommand(binary: HcBinary): String = + "sh -c ${quote("\"\$1\" server status --json /dev/null 2>&1 || { echo 'herdr-core stopped' >&2; exit 3; }; exec \"\$1\" connect")} hc ${quote(binary.path)}" + /** * ` connect` on [socket]: the core's API on the channel's stdin/stdout, never a new core * elsewhere. [session]: another session's name, which a core `connect` started would take, so @@ -195,6 +226,11 @@ object HcDetection { /** Connects to [session]'s core as [probe] found it (null: the default session's); null where it doesn't run. */ suspend fun open(conn: MachineConnection, scope: CoroutineScope, probe: HcProbe, session: String?): HerdrCore? { + if (probe.windows) { + if (session != null || !probe.running) return null + val binary = probe.binary ?: return null + return HerdrCore(binary, HcConnection.open(conn.start(windowsConnectCommand(binary)), scope), null) + } val socket = if (session == null) probe.socket?.takeIf { probe.running } else probe.others.firstOrNull { it.name == session }?.socket socket ?: return null val binary = probe.binary ?: return null diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt index 15b88d79..11770b78 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt @@ -476,6 +476,71 @@ class HerdrCoreTest { assertNull(HcDetection.parse("").binary) } + @Test + fun windowsHasNoSocketAndConnectsTheWayHcDoes() { + val out = """ + windows + core /c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core 12660 + bin /c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core hc 0.1.18 core + """.trimIndent() + val p = HcDetection.parse(out) + assertTrue(p.windows) + assertTrue(p.running) + assertNull(p.socket) + assertEquals("/c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core", p.binary!!.path) + assertEquals("0.1.18", p.binary!!.version) + assertEquals( + """sh -c '"${'$'}1" server status --json /dev/null 2>&1 || { echo '\''herdr-core stopped'\'' >&2; exit 3; }; exec "${'$'}1" connect' hc '/c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core'""", + HcDetection.windowsConnectCommand(p.binary!!), + ) + // herdr-core there, but no core answers `server status`. + assertFalse(HcDetection.parse("windows\n").running) + assertTrue(HcDetection.parse("windows\n").windows) + } + + @Test + fun theProbeScriptAsksHcOnWindows() { + val home = Files.createTempDirectory("hc-win").toFile() + try { + // Git Bash's uname, and a herdr-core whose core runs while `up` exists. + val bin = File(home, "bin").apply { mkdirs() } + File(bin, "uname").apply { writeText("#!/bin/sh\necho MINGW64_NT-10.0-26100\n"); setExecutable(true) } + val up = File(home, "up") + val exe = File(bin, "herdr-core").apply { + writeText( + "#!/bin/sh\n" + + "case \"\$1\" in --version) echo 'hc 0.1.18';; " + + "server) [ -f '${up.path}' ] || exit 5; echo '{\"holder\":{\"pid\":7},\"pid\":12660,\"version\":\"0.1.18\"}';; esac\n", + ) + setExecutable(true) + } + // The command line as SSH hands it to the login shell, from a file so no quoting is in between. + val command = File(home, "probe.sh").apply { writeText(HcDetection.probe + "\n") } + fun run(): String { + val p = ProcessBuilder("sh", command.path) + .apply { environment().apply { keys.removeAll { it.startsWith("HC_") }; put("HOME", home.path); put("PATH", "${bin.path}:/usr/bin:/bin") } } + .redirectErrorStream(true) + .start() + val out = p.inputStream.readBytes().toString(Charsets.UTF_8) + assertEquals(0, p.waitFor(), out) + return out + } + val stopped = HcDetection.parse(run()) + assertTrue(stopped.windows) + assertFalse(stopped.running) + up.writeText("") + val out = run() + val found = HcDetection.parse(out) + assertTrue(found.running, out) + // The path as sh writes it (on Windows itself, `/tmp/…` for `C:\…\Temp\…`). + assertTrue(out.lines().any { it.startsWith("core\t") && it.endsWith("/bin/${exe.name}\t12660") }, out) + assertEquals(true, found.binary?.path?.endsWith("/bin/${exe.name}"), out) + assertEquals("0.1.18", found.binary?.version, out) + } finally { + home.deleteRecursively() + } + } + /** [HcDetection.probe] in `sh` here, as SSH runs it there, with [home] as HOME and its own runtime and state folders. */ private fun probe(home: File): String { val p = ProcessBuilder("sh", "-c", HcDetection.probe) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt index c2a3449e..32b065db 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt @@ -217,7 +217,7 @@ class HerdrPushTest { @Test fun bundledScriptIsTheOneInHost() { assertEquals(script.readText(), HerdrPush.script) - assertEquals("11", HerdrPush.version) + assertEquals("12", HerdrPush.version) } @Test diff --git a/host/herdr-push/README.md b/host/herdr-push/README.md index 5363322e..7b70f792 100644 --- a/host/herdr-push/README.md +++ b/host/herdr-push/README.md @@ -103,6 +103,8 @@ Anyone who sees the code within its five minutes could pair with it, as with any It touches one line in `~/.ssh/authorized_keys` while it waits (then the phone's key is there instead), and `pair-.json` and `pair.lock` in `~/.local/state/herdr-push/`. It needs neither the watcher running nor a phone subscribed. +On Windows (OpenSSH Server with Git Bash as its `DefaultShell`), `pair` is the one command that runs: `python herdr-push pair`. It reads the host keys and `sshd_config` in `C:\ProgramData\ssh`, writes the one-time key's command with forward-slash paths for Bash, and, for a user in Administrators while the stock `Match Group administrators` block is there, uses `administrators_authorized_keys`, which only a terminal opened as administrator can change. + It can't help when sshd reads keys from elsewhere (`AuthorizedKeysFile` in `/etc/ssh/sshd_config`; the phone then says the machine still refuses its key), or when the phone reaches the machine only through another one (a jump host): add those by hand. ## Your own push server diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 54d5921d..356574bf 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# version: 11 +# version: 12 """herdr-push: tells your phone when a herdr agent needs you. It runs on the machine with herdr, watches its agents (`herdr api snapshot`, and the Herdr desktop @@ -42,11 +42,12 @@ that wasn't sealed with it. Python 3 alone, no packages. herdr-push pair [--host NAME[,NAME…]] [--port N] [--session NAME] [--name NAME] show a QR code a phone scans to add this machine (one-time, 5 minutes) +On Windows (OpenSSH with Git Bash as its shell) only `pair` runs: notifications need Linux or macOS. + See host/herdr-push/README.md in the herdr-android repo. """ import base64 -import fcntl import hashlib import hmac import json @@ -63,7 +64,12 @@ import urllib.parse import urllib.request import zlib -VERSION = "11" +try: + import fcntl +except ImportError: # Windows: only `pair` runs there + fcntl = None + +VERSION = "12" NAME = "herdr-push" HOME = os.path.expanduser("~") @@ -77,6 +83,9 @@ LOCK = os.path.join(STATE_DIR, "run.lock") LOG = os.path.join(STATE_DIR, NAME + ".log") # Touched by anything that knows you are here (a shell prompt, an editor hook): counts as typing in herdr. PRESENT = os.path.join(STATE_DIR, "present") +WINDOWS = os.name == "nt" +# Where sshd keeps its config and host keys: Win32-OpenSSH's under ProgramData. +SSHD_DIR = os.path.join(os.environ.get("PROGRAMDATA") or "C:\\ProgramData", "ssh") if WINDOWS else "/etc/ssh" INTERVAL = float(os.environ.get("HERDR_PUSH_INTERVAL", "4")) # ntfy and most push servers take 4 KB; the sealed text stays under this. @@ -1470,7 +1479,24 @@ _KEY_IN_LINE = re.compile(r"(?:^|\s)(" + "|".join(re.escape(t) for t in PAIR_KEY def authorized_keys_path(): - return os.environ.get("HERDR_PAIR_AUTHORIZED_KEYS") or os.path.join(HOME, ".ssh", "authorized_keys") + return os.environ.get("HERDR_PAIR_AUTHORIZED_KEYS") or (WINDOWS and windows_admin_keys()) or os.path.join(HOME, ".ssh", "authorized_keys") + + +def windows_admin_keys(): + """Win32-OpenSSH's own file for administrators' keys when sshd_config's stock `Match Group + administrators` block names it and this user is one; None otherwise.""" + try: + with open(os.path.join(SSHD_DIR, "sshd_config")) as f: + config = f.read() + except OSError: + return None + hit = re.search(r"^[ \t]*Match[ \t]+Group[ \t]+administrators[ \t]*\r?\n[ \t]+AuthorizedKeysFile[ \t]+(\S+)", config, re.I | re.M) + if not hit: + return None + groups = _out(["whoami", "/groups"], timeout=10) or "" + if "S-1-5-32-544" not in groups: # the local Administrators group + return None + return hit.group(1).replace("__PROGRAMDATA__", os.environ.get("PROGRAMDATA") or "C:\\ProgramData") def parse_public_key(line): @@ -1498,7 +1524,7 @@ def key_fingerprint(blob): def ssh_port(): """The port sshd listens on, as its config says (the first Port line), else 22.""" try: - with open("/etc/ssh/sshd_config") as f: + with open(os.path.join(SSHD_DIR, "sshd_config")) as f: for line in f: hit = re.match(r"\s*Port\s+(\d+)\s*$", line, re.I) if hit: @@ -1510,7 +1536,7 @@ def ssh_port(): def host_key_fingerprints(port): """This machine's SSH host keys' fingerprints, Ed25519 first: from /etc/ssh, else as sshd shows them.""" - where = os.environ.get("HERDR_PAIR_HOST_KEYS") or "/etc/ssh" + where = os.environ.get("HERDR_PAIR_HOST_KEYS") or SSHD_DIR blobs = [] for kind in ("ed25519", "ecdsa", "rsa"): try: @@ -1657,7 +1683,12 @@ def edit_authorized_keys(path, state_dir, change): keeping its mode) only when it changed. A one-time key whose time is over goes on the way.""" os.makedirs(state_dir, exist_ok=True) with open(os.path.join(state_dir, "pair.lock"), "a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) + if fcntl: + fcntl.flock(lock, fcntl.LOCK_EX) + else: + import msvcrt + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) try: with open(path) as f: lines = f.read().splitlines() @@ -1736,7 +1767,11 @@ def cmd_pair(rest): " python3 herdr-push pair", file=sys.stderr) return 2 keys = authorized_keys_path() - for path in (script, sys.executable, STATE_DIR, keys): + python, state_dir = sys.executable, STATE_DIR + if WINDOWS: + # Git Bash runs the one-time key's command: Windows paths with forward slashes read the same there. + script, python, state_dir, keys = (p.replace("\\", "/") for p in (script, python, state_dir, keys)) + for path in (script, python, state_dir, keys): if any(c in path for c in '"\\\n'): print("herdr-push pair: the path %s can't go in authorized_keys (a quote or a backslash in it)." % path, file=sys.stderr) return 2 @@ -1755,7 +1790,7 @@ def cmd_pair(rest): import pwd user = pwd.getpwuid(os.getuid()).pw_name except (ImportError, KeyError): - user = os.environ.get("USER") or "me" + user = os.environ.get("USER") or os.environ.get("USERNAME") or "me" name = opts["name"] or socket.gethostname().split(".")[0] or addresses[0] try: seed, blob = one_time_key() @@ -1767,7 +1802,7 @@ def cmd_pair(rest): expires = int(time.time()) + opts["ttl"] state_path = pair_state_path(STATE_DIR, token) write_json(state_path, {"expires": expires, "used": False}, private=True) - command = " ".join(_sh_quote(p) for p in (sys.executable, script, "pair-accept", token, STATE_DIR, keys)) + command = " ".join(_sh_quote(p) for p in (python, script, "pair-accept", token, state_dir, keys)) version = openssh_version() options = [] if version is None or version >= (8, 2): @@ -1781,6 +1816,8 @@ def cmd_pair(rest): except OSError as e: _remove(state_path) print("herdr-push pair: can't add the one-time key to %s: %s" % (keys, e), file=sys.stderr) + if WINDOWS and keys == (windows_admin_keys() or "").replace("\\", "/"): + print("sshd reads administrators' keys from that file: run herdr-push pair in a terminal opened as administrator.", file=sys.stderr) return 1 link = pair_link(name, user, addresses, port, fingerprints, seed, expires, opts["session"]) @@ -1883,6 +1920,8 @@ def cmd_pair_accept(rest): def system_localtime(when): """`when` in the system's time zone, as sshd reads expiry-time, whatever TZ this shell has.""" + if not hasattr(time, "tzset"): # Windows: no TZ to set aside + return time.localtime(when) tz = os.environ.pop("TZ", None) try: time.tzset() @@ -2166,6 +2205,12 @@ LAUNCHD = """ def main(argv): cmd = argv[1] if len(argv) > 1 else "help" rest = argv[2:] + if WINDOWS and cmd not in ("pair", "pair-accept", "version", "_qr", "_seal", "help", "-h", "--help"): + print("herdr-push %s: Windows runs only `herdr-push pair`; notifications need Linux or macOS." % cmd, file=sys.stderr) + return 2 + if WINDOWS and hasattr(sys.stdout, "reconfigure"): + # A pipe there is in the ANSI code page, which has no block characters for the QR code. + sys.stdout.reconfigure(encoding="utf-8") if cmd == "run": return run() if cmd == "subscribe": From bdeef42762718b10641f970e8a153e7443892a0e Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 02/13] herdr-push pair on Windows: find Tailscale where its installer puts it Tailscale's installer adds its folder to the system PATH, but sshd and every shell already open keep the old PATH until restarted, so `pair` missed the Tailscale name and address and the code carried only the local network one. Fall back to %ProgramFiles%\Tailscale\tailscale.exe on Windows. Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 356574bf..a04db14d 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1570,6 +1570,20 @@ def ssh_answers(port): return False +def tailscale_cli(): + """Tailscale's command line: the one on the PATH, else, on Windows, where its installer puts it. The + installer adds that folder to the PATH, but only programs started afterwards see it: sshd, and every + shell open at the time, keep the old one until restarted.""" + found = shutil.which("tailscale") + if found or not WINDOWS: + return found or "tailscale" + for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramW6432"), "C:\\Program Files"): + exe = os.path.join(root, "Tailscale", "tailscale.exe") if root else None + if exe and os.path.isfile(exe): + return exe + return "tailscale" + + def pair_addresses(): """Where a phone may reach this machine, best first: its Tailscale name and address, then the address of its default route (the local network).""" @@ -1579,7 +1593,7 @@ def pair_addresses(): if address and address not in found and not address.startswith("127."): found.append(address) - status = _out(["tailscale", "status", "--json"], timeout=5) + status = _out([tailscale_cli(), "status", "--json"], timeout=5) if status: try: me = json.loads(status).get("Self") or {} From c563f639e38282bd26114b13405aed7d6c7154ba Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 03/13] Pairing on Windows: python in place of python3 The pairing screen and the README say Windows' interpreter is usually python, and the README's Windows paragraph names what herdr-core gives there rather than a list of screens. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- .../kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc000059..b72153d8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Without these secrets, and for local builds, the APK is signed with the normal d curl -fsSLO https://raw.githubusercontent.com/makefunstuff/herdr-android/HEAD/host/herdr-push/herdr-push python3 herdr-push pair ``` + (On Windows the interpreter is usually `python`, see below.) It shows a QR code with where the machine is (its Tailscale name and address, its address on the local network), its user, its SSH host key's fingerprint and a one-time code, valid five minutes. 2. In **Machines**, tap **Add machine**, then **Scan the code**. The phone's own code scanner reads it (Google Play services': the app asks for no camera permission). Without one, **Paste the link** takes the link printed under the code. 3. The app finds the machine at the first address that answers with that host key and shows what it found. **Pair and add** logs in with the one-time code, which can do nothing but hand over this phone's public key: it goes into `~/.ssh/authorized_keys` there, the code is spent, and the app pins the host key and opens the machine. @@ -81,7 +82,7 @@ How it works and what it touches: [`host/herdr-push/README.md`](host/herdr-push/ 2. Add it to `~/.ssh/authorized_keys` on the machine running herdr. 3. In **Machines**, tap **Add machine**, then **Type the address instead**, and enter `user@host[:port]`. The app pins the host key on first connect. -**A Windows machine** works with the Herdr desktop app's herdr-core (the Agents, chat and Terminal screens; classic herdr, notifications and the health screen need Linux or macOS). It needs OpenSSH Server running with [Git for Windows](https://gitforwindows.org)' Bash as its shell, once, in a PowerShell opened as administrator: +**A Windows machine** works with the Herdr desktop app's herdr-core: its agents and their terminals. Classic herdr, Shepherdr and App change need Linux or macOS. It needs OpenSSH Server running with [Git for Windows](https://gitforwindows.org)' Bash as its shell, once, in a PowerShell opened as administrator: ```powershell New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -Value 'C:\Program Files\Git\bin\bash.exe' -PropertyType String -Force diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt index f770ab77..05cebd02 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt @@ -237,7 +237,8 @@ private fun HowTo() { MonoBlock(PAIR_COMMAND) Text( "It shows a code for five minutes. Scanning it adds this phone's key there and checks the machine's " + - "host key, so there's nothing to copy or type. Where herdr-push isn't yet, or doesn't know pair:", + "host key, so there's nothing to copy or type. On Windows, use python in place of python3. " + + "Where herdr-push isn't yet, or doesn't know pair:", style = MaterialTheme.typography.bodyMedium, color = Herdr.colors.muted, ) From 6383eee96d0db85f1a3738f5a830b1e46555212b Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 04/13] herdr-push pair: name the host keys' folder this machine uses Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index a04db14d..47b82bbb 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1792,8 +1792,9 @@ def cmd_pair(rest): port = opts["port"] or ssh_port() fingerprints = host_key_fingerprints(port) if not fingerprints: - print("Couldn't read this machine's SSH host keys (/etc/ssh/ssh_host_*_key.pub), and no SSH server answered " - "on port %d. Is sshd running? (--port N for another port)" % port, file=sys.stderr) + print("Couldn't read this machine's SSH host keys (%s), and no SSH server answered " + "on port %d. Is sshd running? (--port N for another port)" % (os.path.join(SSHD_DIR, "ssh_host_*_key.pub"), port), + file=sys.stderr) return 1 addresses = opts["host"] + [a for a in pair_addresses() if a not in opts["host"]] if not addresses: From 8149cf0a759bebe31233c057ac3a5eace55bf82d Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:21 -0500 Subject: [PATCH 05/13] herdr-push on Windows: herdr-core through hc connect HcPipe talks to `herdr-core connect` over its stdin and stdout like HerdrCore does over the socket, with a 3 s deadline on every answer; core_look uses it on Windows once `server status` says a core runs. `once` now runs there. Version 13. Co-Authored-By: Claude Opus 5.5 --- .../com/makefunstuff/herdr/core/PushTest.kt | 2 +- host/herdr-push/herdr-push | 110 ++++++++++++++++-- 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt index 32b065db..b6aa1b16 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt @@ -217,7 +217,7 @@ class HerdrPushTest { @Test fun bundledScriptIsTheOneInHost() { assertEquals(script.readText(), HerdrPush.script) - assertEquals("12", HerdrPush.version) + assertEquals("13", HerdrPush.version) } @Test diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 47b82bbb..87895598 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# version: 12 +# version: 13 """herdr-push: tells your phone when a herdr agent needs you. It runs on the machine with herdr, watches its agents (`herdr api snapshot`, and the Herdr desktop @@ -52,12 +52,14 @@ import hashlib import hmac import json import os +import queue import re import shutil import signal import socket import subprocess import sys +import threading import time import urllib.error import urllib.parse @@ -69,7 +71,7 @@ try: except ImportError: # Windows: only `pair` runs there fcntl = None -VERSION = "12" +VERSION = "13" NAME = "herdr-push" HOME = os.path.expanduser("~") @@ -330,6 +332,88 @@ class HerdrCore: return self.call("terminal.read", {"terminal": address, "lines": 0}).get("text", "") +def herdr_core_exe(): + """Windows: where herdr-core.exe is, on the PATH or where its installer puts it; None when it isn't there.""" + exe = shutil.which("herdr-core") + if not exe: + exe = os.path.join(os.environ.get("LOCALAPPDATA") or "", "Programs", "herdr-core", "herdr-core.exe") + return exe if os.path.isfile(exe) else None + + +class HcPipe: + """herdr-core's API through `herdr-core connect` (JSON-RPC lines over its stdin and stdout), for Windows, where the + core has no Unix socket. Same calls as HerdrCore. It never starts a core: the caller checks one runs first.""" + + def __init__(self, exe): + self.exe = exe + self.proc = None + self.lines = None + self.n = 0 + + def call(self, method, params=None): + if self.proc is None: + self.proc = subprocess.Popen([self.exe, "connect"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, creationflags=0x08000000) # CREATE_NO_WINDOW + self.lines = queue.Queue() + # A pipe has no read timeout: a thread reads it and the calls wait on the queue. + threading.Thread(target=self._read, args=(self.proc.stdout, self.lines), daemon=True).start() + self._rpc("hello", {"generation": 1}) + return self._rpc(method, params or {}) + + @staticmethod + def _read(f, lines): + try: + for line in f: + lines.put(line) + except (OSError, ValueError): + pass + lines.put(None) + + def _rpc(self, method, params): + self.n += 1 + try: + self.proc.stdin.write((json.dumps({"jsonrpc": "2.0", "id": self.n, "method": method, "params": params}) + "\n").encode()) + self.proc.stdin.flush() + # Short, as HerdrCore's: a core that doesn't answer mustn't hold up classic herdr's notifications. + end = time.time() + 3 + while True: + try: + line = self.lines.get(timeout=max(end - time.time(), 0.01)) + except queue.Empty: + raise RuntimeError("herdr-core did not answer") + if line is None: + raise RuntimeError("herdr-core closed the connection") + out = json.loads(line.decode(errors="replace")) + if out.get("id") == self.n: + break + except Exception: + # What is left of this connection is of no use: the next call starts a new one. + self.close() + raise + if out.get("error"): + raise RuntimeError(out["error"].get("message") or "herdr-core: error") + return out.get("result") or {} + + def close(self): + p, self.proc = self.proc, None + if p is None: + return + # Killed first: closing stdout waits for the reader thread, which waits for the child. + try: + p.kill() + p.wait(timeout=3) + except (OSError, subprocess.TimeoutExpired): + pass + for c in (p.stdin, p.stdout): + try: + c.close() + except (OSError, ValueError): + pass + + def screen(self, address): + return self.call("terminal.read", {"terminal": address, "lines": 0}).get("text", "") + + def hc_agent_name(t): app = ((t.get("status") or {}).get("app") or "").strip().lower() if app: @@ -1014,11 +1098,21 @@ _classic_last = {} def core_look(src, st, now): """herdr-core's agents where a core runs (else None), with [src] set to read their screens.""" - path = hc_socket() + path = None if WINDOWS else hc_socket() try: - if not os.path.exists(path): - raise RuntimeError("no herdr-core socket") - src.core = HerdrCore(path) + if WINDOWS: + exe = herdr_core_exe() + if not exe: + raise RuntimeError("no herdr-core") + # `server status` exits 0 only while a core runs, so a machine without one is never given one. + if subprocess.call([exe, "server", "status", "--json"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=5, creationflags=0x08000000) != 0: + raise RuntimeError("no herdr-core running") + src.core = HcPipe(exe) + else: + if not os.path.exists(path): + raise RuntimeError("no herdr-core socket") + src.core = HerdrCore(path) snap = hc_snapshot(src.core.call("terminal.list").get("terminals") or []) _core_last[src.key] = (snap, now) st.pop("core_problem", None) @@ -2220,8 +2314,8 @@ LAUNCHD = """ def main(argv): cmd = argv[1] if len(argv) > 1 else "help" rest = argv[2:] - if WINDOWS and cmd not in ("pair", "pair-accept", "version", "_qr", "_seal", "help", "-h", "--help"): - print("herdr-push %s: Windows runs only `herdr-push pair`; notifications need Linux or macOS." % cmd, file=sys.stderr) + if WINDOWS and cmd not in ("pair", "pair-accept", "once", "version", "_qr", "_seal", "help", "-h", "--help"): + print("herdr-push %s: on Windows only `herdr-push pair` and `herdr-push once` run so far." % cmd, file=sys.stderr) return 2 if WINDOWS and hasattr(sys.stdout, "reconfigure"): # A pipe there is in the ANSI code page, which has no block characters for the QR code. From 4f953139930733a19d598d151c13bd4d71d3f97c Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:21 -0500 Subject: [PATCH 06/13] herdr-push on Windows: memory, CPU, drives and processes read_memory uses GlobalMemoryStatusEx (swap from the page files), read_cpu GetSystemTimes, read_disks each fixed drive as /c, /d, and top_memory the working set of each process from CIM through PowerShell. Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 152 ++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 87895598..df0235dd 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -57,6 +57,7 @@ import re import shutil import signal import socket +import struct import subprocess import sys import threading @@ -86,6 +87,7 @@ LOG = os.path.join(STATE_DIR, NAME + ".log") # Touched by anything that knows you are here (a shell prompt, an editor hook): counts as typing in herdr. PRESENT = os.path.join(STATE_DIR, "present") WINDOWS = os.name == "nt" +_KERNEL32 = None # kernel32 once loaded, see kernel32() # Where sshd keeps its config and host keys: Win32-OpenSSH's under ProgramData. SSHD_DIR = os.path.join(os.environ.get("PROGRAMDATA") or "C:\\ProgramData", "ssh") if WINDOWS else "/etc/ssh" @@ -621,8 +623,148 @@ def live_event(st, agents, now, first): # ---- health (RAM, CPU, disks) -------------------------------------------------------------------- +def kernel32(): + """Windows: kernel32 with the signatures of the calls health uses (64-bit safe), loaded once.""" + global _KERNEL32 + if _KERNEL32 is None: + import ctypes + from ctypes import wintypes + k = ctypes.WinDLL("kernel32", use_last_error=True) + k.GlobalMemoryStatusEx.argtypes = [ctypes.c_void_p] + k.GlobalMemoryStatusEx.restype = wintypes.BOOL + k.GetSystemTimes.argtypes = [ctypes.POINTER(wintypes.FILETIME)] * 3 + k.GetSystemTimes.restype = wintypes.BOOL + k.GetLogicalDrives.argtypes = [] + k.GetLogicalDrives.restype = wintypes.DWORD + k.GetDriveTypeW.argtypes = [wintypes.LPCWSTR] + k.GetDriveTypeW.restype = wintypes.UINT + k.GetDiskFreeSpaceExW.argtypes = [wintypes.LPCWSTR] + [ctypes.POINTER(ctypes.c_ulonglong)] * 3 + k.GetDiskFreeSpaceExW.restype = wintypes.BOOL + k.SetThreadErrorMode.argtypes = [wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + k.SetThreadErrorMode.restype = wintypes.BOOL + _KERNEL32 = k + return _KERNEL32 + + +def windows_swap(): + """(total, in use) bytes of the page files, from NtQuerySystemInformation(SystemPageFileInformation); None if not told.""" + import ctypes + ntdll = ctypes.WinDLL("ntdll") + ntdll.NtQuerySystemInformation.argtypes = [ctypes.c_ulong, ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong)] + ntdll.NtQuerySystemInformation.restype = ctypes.c_long + size = 4096 + for _ in range(4): + buf = ctypes.create_string_buffer(size) + need = ctypes.c_ulong() + status = ntdll.NtQuerySystemInformation(18, buf, size, ctypes.byref(need)) # SystemPageFileInformation + if status & 0xFFFFFFFF == 0xC0000004: # STATUS_INFO_LENGTH_MISMATCH + size = max(need.value, size * 2) + continue + if status != 0: + return None + # A chain of entries: next entry's offset, then size, in use and peak in 4 KiB pages. + total = used = offset = 0 + while True: + nxt, pages, in_use = struct.unpack_from(" Date: Sat, 7 Nov 2026 21:09:21 -0500 Subject: [PATCH 07/13] herdr-push on Windows: whether someone is at the PC typed_at reads the session's last keyboard or mouse input (GetLastInputInfo against GetTickCount64, modulo 2**32), and screen_unlocked is False while LogonUI.exe runs in this session or the input desktop can't be opened. Both only mean something in the interactive session. `presence` now runs on Windows. Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 116 ++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index df0235dd..7e1e0af7 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -88,6 +88,7 @@ LOG = os.path.join(STATE_DIR, NAME + ".log") PRESENT = os.path.join(STATE_DIR, "present") WINDOWS = os.name == "nt" _KERNEL32 = None # kernel32 once loaded, see kernel32() +_USER32 = None # user32 once loaded, see user32() # Where sshd keeps its config and host keys: Win32-OpenSSH's under ProgramData. SSHD_DIR = os.path.join(os.environ.get("PROGRAMDATA") or "C:\\ProgramData", "ssh") if WINDOWS else "/etc/ssh" @@ -642,10 +643,41 @@ def kernel32(): k.GetDiskFreeSpaceExW.restype = wintypes.BOOL k.SetThreadErrorMode.argtypes = [wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] k.SetThreadErrorMode.restype = wintypes.BOOL + k.GetTickCount64.argtypes = [] + k.GetTickCount64.restype = ctypes.c_ulonglong + k.GetCurrentProcessId.argtypes = [] + k.GetCurrentProcessId.restype = wintypes.DWORD + k.ProcessIdToSessionId.argtypes = [wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + k.ProcessIdToSessionId.restype = wintypes.BOOL + k.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + k.CreateToolhelp32Snapshot.restype = ctypes.c_void_p + k.Process32FirstW.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + k.Process32FirstW.restype = wintypes.BOOL + k.Process32NextW.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + k.Process32NextW.restype = wintypes.BOOL + k.CloseHandle.argtypes = [ctypes.c_void_p] + k.CloseHandle.restype = wintypes.BOOL _KERNEL32 = k return _KERNEL32 +def user32(): + """Windows: user32 with the signatures of the calls presence uses (64-bit safe), loaded once.""" + global _USER32 + if _USER32 is None: + import ctypes + from ctypes import wintypes + u = ctypes.WinDLL("user32", use_last_error=True) + u.GetLastInputInfo.argtypes = [ctypes.c_void_p] + u.GetLastInputInfo.restype = wintypes.BOOL + u.OpenInputDesktop.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + u.OpenInputDesktop.restype = ctypes.c_void_p + u.CloseDesktop.argtypes = [ctypes.c_void_p] + u.CloseDesktop.restype = wintypes.BOOL + _USER32 = u + return _USER32 + + def windows_swap(): """(total, in use) bytes of the page files, from NtQuerySystemInformation(SystemPageFileInformation); None if not told.""" import ctypes @@ -1066,13 +1098,39 @@ def herdr_client(argv): return True +def windows_last_input(now=None): + """Windows: when this session last had keyboard or mouse input, or None if it can't tell. There is no input to read + from an SSH session, so this only means something where the watcher runs in the interactive one.""" + import ctypes + from ctypes import wintypes + + class LASTINPUTINFO(ctypes.Structure): + _fields_ = [("cbSize", wintypes.UINT), ("dwTime", wintypes.DWORD)] + + info = LASTINPUTINFO(ctypes.sizeof(LASTINPUTINFO), 0) + try: + if not user32().GetLastInputInfo(ctypes.byref(info)): + return None + # dwTime is a 32-bit tick count that wraps every 49.7 days, so take the difference modulo 2**32. + idle_ms = ((kernel32().GetTickCount64() & 0xFFFFFFFF) - info.dwTime) % 2 ** 32 + except Exception: # noqa: BLE001 - no user32 or no desktop: it can't tell + return None + return (time.time() if now is None else now) - idle_ms / 1000.0 + + def typed_at(ps=None): - """When someone last typed in a herdr client here (its terminal's last input), or touched the presence file; None.""" + """When someone last typed in a herdr client here (its terminal's last input), or touched the presence file; None. + On Windows it is the last keyboard or mouse input of the session instead (see windows_last_input).""" times = [] try: times.append(os.stat(PRESENT).st_mtime) except OSError: pass + if WINDOWS: + last = windows_last_input() + if last is not None: + times.append(last) + return max(times) if times else None ps = _out(["ps", "-A", "-o", "tty=,args="]) if ps is None else ps for line in (ps or "").splitlines(): parts = line.split() @@ -1090,6 +1148,8 @@ def screen_unlocked(): forced = os.environ.get("HERDR_PUSH_SCREEN") # tests: locked, unlocked or none if forced: return {"locked": False, "unlocked": True}.get(forced) + if WINDOWS: + return windows_unlocked() if sys.platform == "darwin": return _mac_unlocked() sessions = _out(["loginctl", "list-sessions", "--no-legend"]) @@ -1113,6 +1173,56 @@ def screen_unlocked(): return desktop +def windows_lock_screen(): + """Windows: whether LogonUI.exe, the lock screen, runs in this session. It runs once per session, so the lock screen of + another signed-in user doesn't count. Walks the process list with Toolhelp (a few milliseconds, no process spawned).""" + import ctypes + from ctypes import wintypes + + class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", wintypes.LONG), ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * 260)] + + k = kernel32() + mine = wintypes.DWORD() + if not k.ProcessIdToSessionId(k.GetCurrentProcessId(), ctypes.byref(mine)): + mine = None + snap = k.CreateToolhelp32Snapshot(0x2, 0) # TH32CS_SNAPPROCESS + if snap is None or snap == ctypes.c_void_p(-1).value: # INVALID_HANDLE_VALUE + return False + try: + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(entry) + ok = k.Process32FirstW(snap, ctypes.byref(entry)) + while ok: + if entry.szExeFile.lower() == "logonui.exe": + theirs = wintypes.DWORD() + if mine is None or (k.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(theirs)) and theirs.value == mine.value): + return True + ok = k.Process32NextW(snap, ctypes.byref(entry)) + finally: + k.CloseHandle(snap) + return False + + +def windows_unlocked(): + """Windows: whether this session's screen is unlocked. Locked while the lock screen runs or the input desktop can't be + opened (it is the secure one then). Only means something in the interactive session: an SSH session has no input + desktop of its own, so it reads as locked there.""" + try: + if windows_lock_screen(): + return False + desktop = user32().OpenInputDesktop(0, False, 0x100) # DESKTOP_SWITCHDESKTOP + if not desktop: + return False + user32().CloseDesktop(desktop) + return True + except Exception: # noqa: BLE001 - it can't tell + return None + + def _mac_unlocked(): out = _out(["ioreg", "-n", "Root", "-d1", "-a"]) if not out: @@ -2462,8 +2572,8 @@ LAUNCHD = """ def main(argv): cmd = argv[1] if len(argv) > 1 else "help" rest = argv[2:] - if WINDOWS and cmd not in ("pair", "pair-accept", "once", "version", "_qr", "_seal", "help", "-h", "--help"): - print("herdr-push %s: on Windows only `herdr-push pair` and `herdr-push once` run so far." % cmd, file=sys.stderr) + if WINDOWS and cmd not in ("pair", "pair-accept", "once", "presence", "version", "_qr", "_seal", "help", "-h", "--help"): + print("herdr-push %s: on Windows only `herdr-push pair`, `once` and `presence` run so far." % cmd, file=sys.stderr) return 2 if WINDOWS and hasattr(sys.stdout, "reconfigure"): # A pipe there is in the ANSI code page, which has no block characters for the QR code. From 37775046a02bc140a083d0e4a12e0072abdda2cc Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:21 -0500 Subject: [PATCH 08/13] herdr-push on Windows: start with the user's session The watcher runs in the signed-in user's own desktop session: install writes the HKCU Run value and a per-user scheduled task (/it, no elevation), and install and ensure start it through that task, never as a child of SSH. Locks use a byte of the lock file far from the pid (Windows locks are mandatory), run logs to the log file under pythonw.exe, status says whether a session is interactive, and tick closes its herdr-core connections in a finally. Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 343 ++++++++++++++++++++++++++----------- 1 file changed, 240 insertions(+), 103 deletions(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 7e1e0af7..477e4da2 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -33,7 +33,7 @@ that wasn't sealed with it. Python 3 alone, no packages. herdr-push test [] send a test notification now herdr-push status how it stands, as JSON herdr-push ensure start the watcher when it isn't running - herdr-push install start it now and with the machine (systemd, launchd or cron) + herdr-push install start it now and with the machine (systemd, launchd, cron or, on Windows, your sign-in) herdr-push uninstall [--purge] stop it and remove the start-up entry (--purge: and its settings) herdr-push once [--dry-run] one look, printing what it would send herdr-push journal [--session NAME] [--since SECONDS] @@ -42,7 +42,9 @@ that wasn't sealed with it. Python 3 alone, no packages. herdr-push pair [--host NAME[,NAME…]] [--port N] [--session NAME] [--name NAME] show a QR code a phone scans to add this machine (one-time, 5 minutes) -On Windows (OpenSSH with Git Bash as its shell) only `pair` runs: notifications need Linux or macOS. +On Windows (OpenSSH with Git Bash as its shell) it runs in your own desktop session, started when you sign in (a +Run entry) and, from `install` or `ensure`, through a scheduled task of yours: never as a child of the SSH session. +No elevation is asked. See host/herdr-push/README.md in the herdr-android repo. """ @@ -69,7 +71,7 @@ import zlib try: import fcntl -except ImportError: # Windows: only `pair` runs there +except ImportError: # Windows: msvcrt's locks stand in for it fcntl = None VERSION = "13" @@ -87,6 +89,10 @@ LOG = os.path.join(STATE_DIR, NAME + ".log") # Touched by anything that knows you are here (a shell prompt, an editor hook): counts as typing in herdr. PRESENT = os.path.join(STATE_DIR, "present") WINDOWS = os.name == "nt" +NO_WINDOW = 0x08000000 if WINDOWS else 0 # CREATE_NO_WINDOW for the commands run: a watcher without a console opens none +# The one byte of the lock file that a running watcher locks. Windows locks are mandatory, so it is not one that holds the pid. +LOCK_BYTE = 1 << 20 +RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" # where Windows starts a user's programs at sign-in _KERNEL32 = None # kernel32 once loaded, see kernel32() _USER32 = None # user32 once loaded, see user32() # Where sshd keeps its config and host keys: Win32-OpenSSH's under ProgramData. @@ -234,7 +240,7 @@ class Source: def run(self, args, timeout=20): r = subprocess.run(args, env=self.env(), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, timeout=timeout) + stderr=subprocess.PIPE, timeout=timeout, creationflags=NO_WINDOW) if r.returncode != 0: raise RuntimeError((r.stderr.decode(errors="replace").strip().splitlines() or ["exit %d" % r.returncode])[-1]) return r.stdout.decode(errors="replace") @@ -974,7 +980,7 @@ def usage_look(st, now): st["at"] = now try: r = subprocess.run([sys.executable, USAGE_SCRIPT, "limits"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, timeout=120) + stderr=subprocess.DEVNULL, timeout=120, creationflags=NO_WINDOW) lines = [l for l in r.stdout.decode("utf-8", "replace").splitlines() if l.strip().startswith("{")] out = json.loads(lines[-1]) if lines else {} except Exception as e: # noqa: BLE001 @@ -1173,9 +1179,10 @@ def screen_unlocked(): return desktop -def windows_lock_screen(): - """Windows: whether LogonUI.exe, the lock screen, runs in this session. It runs once per session, so the lock screen of - another signed-in user doesn't count. Walks the process list with Toolhelp (a few milliseconds, no process spawned).""" +def windows_sessions(exe): + """Windows: the session id of each running process whose image is called [exe] (lower case), and of this one: + ([ids], mine), mine None if it can't tell. Walks the process list with Toolhelp (a few milliseconds, no process + spawned). None instead if the list can't be read.""" import ctypes from ctypes import wintypes @@ -1187,24 +1194,40 @@ def windows_lock_screen(): k = kernel32() mine = wintypes.DWORD() - if not k.ProcessIdToSessionId(k.GetCurrentProcessId(), ctypes.byref(mine)): - mine = None + mine = mine.value if k.ProcessIdToSessionId(k.GetCurrentProcessId(), ctypes.byref(mine)) else None snap = k.CreateToolhelp32Snapshot(0x2, 0) # TH32CS_SNAPPROCESS if snap is None or snap == ctypes.c_void_p(-1).value: # INVALID_HANDLE_VALUE - return False + return None + ids = [] try: entry = PROCESSENTRY32W() entry.dwSize = ctypes.sizeof(entry) ok = k.Process32FirstW(snap, ctypes.byref(entry)) while ok: - if entry.szExeFile.lower() == "logonui.exe": + if entry.szExeFile.lower() == exe: theirs = wintypes.DWORD() - if mine is None or (k.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(theirs)) and theirs.value == mine.value): - return True + ids.append(theirs.value if k.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(theirs)) else None) ok = k.Process32NextW(snap, ctypes.byref(entry)) finally: k.CloseHandle(snap) - return False + return ids, mine + + +def windows_lock_screen(): + """Windows: whether LogonUI.exe, the lock screen, runs in this session. It runs once per session, so the lock screen of + another signed-in user doesn't count.""" + found = windows_sessions("logonui.exe") + if found is None: + return False + ids, mine = found + return any(mine is None or i == mine for i in ids) + + +def windows_interactive(): + """Windows: whether someone is signed in at the PC, which is when explorer.exe runs in a session other than 0 (services + and SSH sessions live in session 0, a desktop session never does).""" + found = windows_sessions("explorer.exe") + return found is not None and any(i is not None and i != 0 for i in found[0]) def windows_unlocked(): @@ -1412,88 +1435,91 @@ def tick(state, n, dry_run=False, now=None): return seen_here[0] srcs = sources(subs) - for key, (src, group) in srcs.items(): - st = state.setdefault("sessions", {}).setdefault(key, {}) - live = any(s.get("live") for s in group) - watch_panes = watched(group, "watch_panes") - events = [] - agents = None - # What the journal saw last; its first look only learns how things stand. - jst = st.setdefault("journal", {}) - noted = [] - core = core_look(src, st, now) if src.session == "default" else None - # herdr-core's agents last told, on a machine without classic herdr: they go when the core does. - told_core = any(p.startswith(HC_PREFIX) for p in st.get("agents") or {}) - if src.herdr or core is not None or told_core: + try: + for key, (src, group) in srcs.items(): + st = state.setdefault("sessions", {}).setdefault(key, {}) + live = any(s.get("live") for s in group) + watch_panes = watched(group, "watch_panes") + events = [] + agents = None + # What the journal saw last; its first look only learns how things stand. + jst = st.setdefault("journal", {}) + noted = [] + core = core_look(src, st, now) if src.session == "default" else None + # herdr-core's agents last told, on a machine without classic herdr: they go when the core does. + told_core = any(p.startswith(HC_PREFIX) for p in st.get("agents") or {}) + if src.herdr or core is not None or told_core: + try: + snap = {"workspaces": [], "tabs": [], "agents": []} + if src.herdr: + try: + snap = src.snapshot() + _classic_last[key] = snap + st.pop("classic_problem", None) + except Exception: # noqa: BLE001 - herdr-core's agents are still told, classic herdr's kept as they were + # Without classic herdr's last snapshot, one that had agents keeps them by not looking now. + told_classic = any(not p.startswith(HC_PREFIX) for p in st.get("agents") or {}) + if key not in _classic_last and (told_classic or (core is None and not told_core)): + raise + log_once(st, "classic_problem", "herdr session %s:" % key, sys.exc_info()[1]) + snap = _classic_last.get(key, snap) + if core is not None: + snap = merged(snap, core) + # A core seen for the first time (herdr-push updated to read it): its agents + # as they stand are learnt, not told, as on a first look. + if not st.get("core_known"): + for a in core.get("agents") or []: + if "agents" in st: + st["agents"].setdefault(a["pane_id"], a.get("agent_status", "unknown")) + if "agents" in jst: + jst["agents"].setdefault(a["pane_id"], "%s#%s" % (a.get("agent_status", "unknown"), a.get("state_change_seq", ""))) + st["core_known"] = True + first = "agents" not in st + told = agent_events(src, snap, st.setdefault("agents", {}), first) + events += told + events += watch_agent_events(snap, st.setdefault("watch_agents", {}), watch_panes, + {e["agent"].get("pane_id") for e in told}) + learn = "agents" not in jst or first + noted += journal_agents(snap, jst.setdefault("agents", {}), learn, now) + if live: + agents = live_agents(snap, st, now) + st.pop("problem", None) + except Exception as e: # noqa: BLE001 + if st.get("problem") != str(e): + log("herdr session %s:" % key, e) + st["problem"] = str(e) try: - snap = {"workspaces": [], "tabs": [], "agents": []} - if src.herdr: - try: - snap = src.snapshot() - _classic_last[key] = snap - st.pop("classic_problem", None) - except Exception: # noqa: BLE001 - herdr-core's agents are still told, classic herdr's kept as they were - # Without classic herdr's last snapshot, one that had agents keeps them by not looking now. - told_classic = any(not p.startswith(HC_PREFIX) for p in st.get("agents") or {}) - if key not in _classic_last and (told_classic or (core is None and not told_core)): - raise - log_once(st, "classic_problem", "herdr session %s:" % key, sys.exc_info()[1]) - snap = _classic_last.get(key, snap) - if core is not None: - snap = merged(snap, core) - # A core seen for the first time (herdr-push updated to read it): its agents - # as they stand are learnt, not told, as on a first look. - if not st.get("core_known"): - for a in core.get("agents") or []: - if "agents" in st: - st["agents"].setdefault(a["pane_id"], a.get("agent_status", "unknown")) - if "agents" in jst: - jst["agents"].setdefault(a["pane_id"], "%s#%s" % (a.get("agent_status", "unknown"), a.get("state_change_seq", ""))) - st["core_known"] = True - first = "agents" not in st - told = agent_events(src, snap, st.setdefault("agents", {}), first) - events += told - events += watch_agent_events(snap, st.setdefault("watch_agents", {}), watch_panes, - {e["agent"].get("pane_id") for e in told}) - learn = "agents" not in jst or first - noted += journal_agents(snap, jst.setdefault("agents", {}), learn, now) - if live: - agents = live_agents(snap, st, now) - st.pop("problem", None) - except Exception as e: # noqa: BLE001 - if st.get("problem") != str(e): - log("herdr session %s:" % key, e) - st["problem"] = str(e) - try: - if noted: - journal_add(key, noted, now) - elif "at" not in jst: - journal_start(key, now) - # Up to when the journal is complete (the watcher may be stopped for a while). - jst["at"] = int(now) - except Exception as e: # noqa: BLE001 - the journal is a nicety next to the pushes - log("journal:", e) - if live: - ev = live_event(st, agents, now, first="live_sent" not in st) - if ev is not None: - events.append(ev) - else: - for k in ("live_sent", "live_agents", "live_agents_since"): - st.pop(k, None) - if not events: - continue - sent[key] = events - for sub in group: - record = state.setdefault("phones", {}).setdefault(sub["id"], {}) - if gone(record, sub): - continue - mine = [e for e in events if (e.get("type") != "live" or sub.get("live")) and sub["id"] in e.get("for", [sub["id"]])] - if dry_run or not mine: + if noted: + journal_add(key, noted, now) + elif "at" not in jst: + journal_start(key, now) + # Up to when the journal is complete (the watcher may be stopped for a while). + jst["at"] = int(now) + except Exception as e: # noqa: BLE001 - the journal is a nicety next to the pushes + log("journal:", e) + if live: + ev = live_event(st, agents, now, first="live_sent" not in st) + if ev is not None: + events.append(ev) + else: + for k in ("live_sent", "live_agents", "live_agents_since"): + st.pop(k, None) + if not events: continue - send(sub, mine, record, here) - for src, _ in srcs.values(): - if src.core: - src.core.close() + sent[key] = events + for sub in group: + record = state.setdefault("phones", {}).setdefault(sub["id"], {}) + if gone(record, sub): + continue + mine = [e for e in events if (e.get("type") != "live" or sub.get("live")) and sub["id"] in e.get("for", [sub["id"]])] + if dry_run or not mine: + continue + send(sub, mine, record, here) + finally: + # Also when a look fails: a herdr-core connection that stays open is a process (Windows) that stays on. + for src, _ in srcs.values(): + if src.core: + src.core.close() if subs: ev = health_look(state.setdefault("health", {}), now) if ev is not None: @@ -1516,12 +1542,27 @@ def tick(state, n, dry_run=False, now=None): return sent +def lock_byte(f, mode): + """Windows: locks or unlocks (msvcrt.LK_NBLCK, LK_UNLCK) the byte of [f] that tells a watcher runs. It is far past the + pid, which the locked byte would hide from the other processes. Raises OSError when another process holds it.""" + import msvcrt + f.seek(LOCK_BYTE) + msvcrt.locking(f.fileno(), mode, 1) + + def run(): os.makedirs(STATE_DIR, exist_ok=True) + if WINDOWS and sys.stderr is None: + # pythonw.exe has no console, so no stderr either: what log() says goes to the log file. + sys.stdout = sys.stderr = open(LOG, "a", buffering=1, encoding="utf-8") # Not "w": that would wipe the running watcher's pid before finding out it runs. lock = open(LOCK, "a+") try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if WINDOWS: + import msvcrt + lock_byte(lock, msvcrt.LK_NBLCK) + else: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: log("already running") return 0 @@ -1556,12 +1597,19 @@ def running_pid(): return None with f: try: - fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + if WINDOWS: + import msvcrt + lock_byte(f, msvcrt.LK_NBLCK) + else: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: f.seek(0) pid = f.read().strip() return int(pid) if pid.isdigit() else -1 - fcntl.flock(f, fcntl.LOCK_UN) + if WINDOWS: + lock_byte(f, msvcrt.LK_UNLCK) + else: + fcntl.flock(f, fcntl.LOCK_UN) return None @@ -2362,6 +2410,14 @@ def cmd_test(pid=None): def autostart(): + if WINDOWS: + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key: + winreg.QueryValueEx(key, NAME) + return "windows-run" + except OSError: + return "none" if sys.platform == "darwin": return "launchd" if os.path.exists(launchd_plist()) else "none" if os.path.exists(systemd_unit()): @@ -2384,8 +2440,11 @@ def cmd_status(): "ok": r.get("ok"), "error": r.get("error"), "gone": gone(r, s)}) problems = {k: v["problem"] for k, v in state.get("sessions", {}).items() if v.get("problem")} pid = running_pid() - out({"ok": True, "version": VERSION, "running": pid is not None, "pid": pid, "autostart": autostart(), - "phones": phones, "problems": problems, "path": os.path.abspath(__file__)}) + info = {"ok": True, "version": VERSION, "running": pid is not None, "pid": pid, "autostart": autostart(), + "phones": phones, "problems": problems, "path": os.path.abspath(__file__)} + if WINDOWS: + info["interactive"] = windows_interactive() # false: no one is signed in, so there is nowhere to run it + out(info) return 0 @@ -2402,6 +2461,12 @@ def start_background(): def cmd_ensure(): + if WINDOWS: + # Never a child of this (SSH) session: the task starts it in the signed-in user's own session. + if running_pid() is None and windows_interactive() and (windows_task("/query")[0] or windows_task_create()[0]): + windows_task("/run") + wait_for_watcher(5) + return cmd_status() if running_pid() is None: kind = autostart() if kind == "systemd": @@ -2429,7 +2494,64 @@ def has_systemd(): return False +def wait_for_watcher(seconds): + """Waits up to [seconds] for the watcher to hold the lock; whether it does.""" + for _ in range(int(seconds * 10)): + if running_pid() is not None: + return True + time.sleep(0.1) + return running_pid() is not None + + +def windows_command(): + """What starts the watcher on Windows: pythonw.exe, which opens no console window, next to this Python.""" + py = os.path.join(os.path.dirname(sys.executable), "pythonw.exe") + if not os.path.exists(py): + py = sys.executable + return '"%s" "%s" run' % (py, os.path.abspath(__file__)) + + +def windows_task(*args): + """Runs schtasks on this user's task of the watcher: (whether it worked, what it said).""" + try: + r = subprocess.run(["schtasks"] + list(args) + ["/tn", NAME], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=30, creationflags=NO_WINDOW) + except (OSError, subprocess.TimeoutExpired) as e: + return False, str(e) + return r.returncode == 0, r.stdout.decode(errors="replace").strip() + + +def windows_task_create(): + """The task that starts the watcher in the user's interactive session (/it: only while they are signed in, no + password, no elevation). It is what `schtasks /run` starts from an SSH session, which can't open a window there.""" + return windows_task("/create", "/sc", "once", "/st", "00:00", "/it", "/f", "/tr", windows_command()) + + +def windows_install(): + import winreg + ok, err = windows_task_create() + if not ok: + out({"ok": False, "msg": "couldn't create the task: " + err, "autostart": autostart(), "pid": running_pid(), "version": VERSION}) + return 1 + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key: + winreg.SetValueEx(key, NAME, 0, winreg.REG_SZ, windows_command()) + note = "" + if windows_interactive(): + if running_pid() is not None: + stop() # one of an older install: the task starts the one that is here now + windows_task("/run") + wait_for_watcher(5) + else: + note = "no one is signed in at the PC: it starts with the next sign-in" + pid = running_pid() + out({"ok": pid is not None or bool(note), "msg": note or ("running" if pid is not None else "the task didn't start it"), + "autostart": autostart(), "pid": pid, "version": VERSION}) + return 0 if pid is not None or note else 1 + + def cmd_install(): + if WINDOWS: + return windows_install() me = os.path.abspath(__file__) note = "" if sys.platform == "darwin": @@ -2494,7 +2616,25 @@ def stop(): time.sleep(0.1) +def windows_uninstall(): + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as key: + winreg.DeleteValue(key, NAME) + except OSError: + pass + windows_task("/delete", "/f") + + def cmd_uninstall(purge): + if WINDOWS: + windows_uninstall() + stop() + if purge: + shutil.rmtree(CONFIG_DIR, ignore_errors=True) + shutil.rmtree(STATE_DIR, ignore_errors=True) + out({"ok": True, "msg": "removed"}) + return 0 if sys.platform == "darwin" and os.path.exists(launchd_plist()): subprocess.run(["launchctl", "unload", "-w", launchd_plist()], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) os.remove(launchd_plist()) @@ -2572,9 +2712,6 @@ LAUNCHD = """ def main(argv): cmd = argv[1] if len(argv) > 1 else "help" rest = argv[2:] - if WINDOWS and cmd not in ("pair", "pair-accept", "once", "presence", "version", "_qr", "_seal", "help", "-h", "--help"): - print("herdr-push %s: on Windows only `herdr-push pair`, `once` and `presence` run so far." % cmd, file=sys.stderr) - return 2 if WINDOWS and hasattr(sys.stdout, "reconfigure"): # A pipe there is in the ANSI code page, which has no block characters for the QR code. sys.stdout.reconfigure(encoding="utf-8") From 62865440aa4b85624bab80da73f16a6d75557d2b Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:21 -0500 Subject: [PATCH 09/13] herdr-push on Windows: a task without a time limit The scheduled task is created from an XML (schtasks /create /xml) instead of the bare command line: a task made that way stops after 72 hours and doesn't run on battery, which would kill a watcher started through it. The XML has no time limit, no battery rules and no trigger (the Run value starts it at sign-in, the task only runs on demand), and still runs as the current user with no elevation. Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 50 +++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 477e4da2..56485390 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -62,6 +62,7 @@ import socket import struct import subprocess import sys +import tempfile import threading import time import urllib.error @@ -2504,11 +2505,11 @@ def wait_for_watcher(seconds): def windows_command(): - """What starts the watcher on Windows: pythonw.exe, which opens no console window, next to this Python.""" + """What starts the watcher on Windows: (pythonw.exe, which opens no console window, next to this Python; its arguments).""" py = os.path.join(os.path.dirname(sys.executable), "pythonw.exe") if not os.path.exists(py): py = sys.executable - return '"%s" "%s" run' % (py, os.path.abspath(__file__)) + return py, '"%s" run' % os.path.abspath(__file__) def windows_task(*args): @@ -2521,10 +2522,47 @@ def windows_task(*args): return r.returncode == 0, r.stdout.decode(errors="replace").strip() +TASK_XML = """ + + + + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + PT0S + + + + %s + %s + + + +""" + + def windows_task_create(): - """The task that starts the watcher in the user's interactive session (/it: only while they are signed in, no - password, no elevation). It is what `schtasks /run` starts from an SSH session, which can't open a window there.""" - return windows_task("/create", "/sc", "once", "/st", "00:00", "/it", "/f", "/tr", windows_command()) + """The task that starts the watcher in the user's interactive session (InteractiveToken: only while they are signed + in, no password, no elevation). It is what `schtasks /run` starts from an SSH session, which can't open a window + there. No trigger (the Run value starts it at sign-in), and none of the limits a task has by default: it would stop + the watcher after 3 days, or on battery.""" + from xml.sax.saxutils import escape + py, args = windows_command() + fd, path = tempfile.mkstemp(suffix=".xml") + try: + with os.fdopen(fd, "w", encoding="utf-16") as f: # schtasks reads a UTF-16 file + f.write(TASK_XML % (escape(py), escape(args))) + return windows_task("/create", "/xml", path, "/f") + finally: + os.remove(path) def windows_install(): @@ -2534,7 +2572,7 @@ def windows_install(): out({"ok": False, "msg": "couldn't create the task: " + err, "autostart": autostart(), "pid": running_pid(), "version": VERSION}) return 1 with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key: - winreg.SetValueEx(key, NAME, 0, winreg.REG_SZ, windows_command()) + winreg.SetValueEx(key, NAME, 0, winreg.REG_SZ, '"%s" %s' % windows_command()) note = "" if windows_interactive(): if running_pid() is not None: From 19a86acb0dd29b49248363df21018579afc7fd4e Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:21 -0500 Subject: [PATCH 10/13] Notifications from a Windows machine Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++++ host/herdr-push/README.md | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b72153d8..4c708576 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -Value 'C:\Prog Then pair as above with `python herdr-push pair`. For a user in Administrators, Windows' sshd reads keys from `C:\ProgramData\ssh\administrators_authorized_keys`, so run `pair` in a terminal opened as administrator too (or add the key there by hand). +For notifications, the PC has to be awake and signed in: the watcher runs in your signed-in session, and herdr-push needs a Python 3 there (the app finds `python3`, `python` or `py -3`, and skips the Microsoft Store's stand-in). See [Notifications while the app is closed](#notifications-while-the-app-is-closed). + Machines reachable only through another one can use it as a jump host ("Connect through"). For zero-touch or read-only machines, **Allow host scripts** (on by default) can be turned off so the app skips copying its helper scripts (`herdr-usage`, `herdr-logins`, `herdr-push`) there. From a machine's menu, **Import herdr machines** adds the machines its herdr already knows (`herdr machine list`). ## How it talks to herdr @@ -218,6 +220,8 @@ Two ways in, both on every machine the app knows: Each state is shown once, however it arrives, and comes down again when it is over (answered on the desktop, the agent back at work). The switch next to a machine in Settings leaves it out. +**On a Windows PC** the app sets herdr-push up the same way, with no administrator rights: a `Run` entry under your user starts it when you sign in, and a scheduled task of yours starts it right away in your desktop session. It reads the Herdr desktop app's herdr-core (where one runs), RAM, CPU, drives and the biggest processes, and tells the phone how long ago you last used the keyboard or mouse and whether the session is locked. The PC has to be awake and signed in: with nobody signed in, Settings says it can't watch. The phone needs ntfy (or another UnifiedPush app), as for any machine. Classic herdr, Shepherdr and App change are not there. + ### What reaches the phone **Settings → Notifications → What reaches the phone** has a switch for each kind: **Needs you** (an agent asks, a machine runs low), **Finished** (an agent ended its turn), **Went wrong** (a review or a check failed) and **Limits** (an AI provider at 80% or 95% of a limit or out of it; see Usage and limits). Finished is off unless you turn it on: a ping for every finished turn teaches you to ignore the ones that matter. What is switched off isn't shown, and turning it on later starts from then. diff --git a/host/herdr-push/README.md b/host/herdr-push/README.md index 7b70f792..586a0480 100644 --- a/host/herdr-push/README.md +++ b/host/herdr-push/README.md @@ -36,6 +36,19 @@ A machine that is offline is set up the next time the app connects to it. Machin The app also **looks at its machines itself** while it is closed (every 15 minutes by default; Settings → Notifications → The app also looks), with an exact alarm. That covers a machine without python3, or a push that got lost. Pushes arrive within seconds; the app's own looks only as often as you set. +## On Windows + +herdr-push runs on a Windows PC with Git Bash under OpenSSH Server (see the README's "A Windows machine"), with Python 3.6 or newer. The app installs it as it does elsewhere; by hand, `python herdr-push install` does the same. + +It starts without administrator rights, in two ways: + +- A `Run` value under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, `"" "