diff --git a/WINDOWS_PORT_HANDOFF.md b/WINDOWS_PORT_HANDOFF.md new file mode 100644 index 0000000..38def89 --- /dev/null +++ b/WINDOWS_PORT_HANDOFF.md @@ -0,0 +1,407 @@ +# Windows Port Handoff + +## What this project is + +`iai-personal-memory-engine` (repo at `C:\Users\Daniel Hertz\Documents\GitHub\iai-personal-memory-engine`) +is a local MCP server that gives Claude Code persistent long-term memory across sessions. +It captures every conversation, builds a personal model of the user, and injects relevant +context at session start — automatically. It is Python + Rust (PyO3), with a Node.js MCP wrapper. + +It was macOS-only. We are porting it to Windows. + +## What has already been done (Step 1 — committed) + +**Commit:** `1dc1d64` — "Add platform-agnostic IPC transport layer for Windows porting" + +Created `src/iai_mcp/_ipc.py` — a platform-agnostic IPC abstraction module. + +- On POSIX: delegates to the existing Unix-domain socket at `~/.iai-mcp/.daemon.sock` +- On Windows: uses TCP loopback `127.0.0.1:`, port stored in `~/.iai-mcp/.daemon.port` + +Updated all 9 callsites that previously used raw `asyncio.open_unix_connection` / +`asyncio.start_unix_server` / `socket.AF_UNIX`: +- `src/iai_mcp/concurrency.py` +- `src/iai_mcp/socket_server.py` +- `src/iai_mcp/cli/__init__.py` +- `src/iai_mcp/core/__init__.py` +- `src/iai_mcp/direct_write.py` +- `src/iai_mcp/daemon/_watchdog.py` +- `src/iai_mcp/doctor/_lifecycle_checks.py` +- `src/iai_mcp/doctor/__init__.py` +- `src/iai_mcp/semantic_recall.py` + +## Completion Status + +**Steps 1-7: COMPLETED** ✅ + +- **Step 1** (`1dc1d64`): Platform-agnostic IPC (Unix sockets → TCP loopback on Windows) +- **Step 2** (`8154b9b`): fcntl file locking → `_filelock.py` shim +- **Steps 3+4+9** (`c009736`): POSIX signals, resource module, CLI daemon logging +- **Steps 7+10** (`8ecd257`): uid/geteuid guards, os.fchmod guards, icacls file security +- **Step 5** (`0e8321c`): Windows Task Scheduler daemon installer (schtasks.exe) +- **Step 6** (`f4865bf`): PowerShell hook equivalents (.ps1 scripts + hook installer updates) +- **Step 7 — bench files** (`59839a3`): `resource.getrusage()` → psutil `peak_wset` on Windows; + POSIX path unchanged. All four bench files (`memory_footprint.py`, + `memorygraph_memory.py`, `consolidation_rss_peak.py`, `embed_warm_cost.py`) + now import cleanly on Windows. +- **Fix** (`13808e1`): `lifecycle_event_log.py` was importing `timedelta` / + `timezone` from `iai_mcp._filelock` (regression from the Step 2 rewrite). + Moved them back to the `datetime` import. Was broken on ALL platforms, not + just Windows — surfaced only when we exercised the full import chain. +- **Help text** (`019e52f`): `daemon install` / `uninstall` / `start` / `stop` / + `logs` argparse help now lists the Windows backend (Task Scheduler / schtasks / + `%APPDATA%\\iai-mcp\\logs`) alongside launchd and systemd. + +## Verified on Windows in-situ (this session) + +Running from `C:\\Users\\Daniel Hertz\\Documents\\GitHub\\iai-personal-memory-engine` +with system Python 3.14 (no venv, no full project install): + +- All 23 files touched by the port: **AST parse clean**. +- 10/10 ported runtime modules (excluding ones that need numpy/hnswlib at + import-time): **import clean** on Windows. +- `python -m iai_mcp.cli --help`: **lists all subcommands**, no crash. +- `python -m iai_mcp.cli daemon install --dry-run`: **emits a valid Task + Scheduler XML** with the right user, pythonw path, log dir, and LogonTrigger. + XML file write uses `encoding="utf-16"` — schtasks-compatible. +- `python -m iai_mcp.cli capture-hooks status`: **detects all three `.ps1` + hook templates** in the source tree, reports the expected + `~/.claude/hooks/*.ps1` install paths and "NOT WIRED" status. + +Cosmetic only (not blocking): the em-dash in the schtasks XML description +renders as `�` when printed to a cp1252 console, but the file written to +disk for `schtasks /Create /XML` is UTF-16 and round-trips fine. + +## What remains + +Full end-to-end testing inside a real venv (`pip install -e ".[dev]"`, +which pulls the Rust extension via setuptools-rust + numpy + hnswlib), +then actually running `daemon install --yes` and `capture-hooks install` +to verify the scheduled task and `~/.claude/settings.json` registration +land correctly. These would be live actions on the user's machine and were +deliberately not run autonomously. + +### Bench Files — resource.getrusage() (OPTIONAL — not required for daemon) + +Lower priority, affects only benchmarking tools (not runtime code). + +`fcntl` is POSIX-only. On Windows, importing any of these files raises `ModuleNotFoundError`. + +Files to fix: +- `src/iai_mcp/capture_queue.py` — uses `fcntl.flock()` +- `src/iai_mcp/hippo/_db.py` — uses `fcntl.flock()` +- `src/iai_mcp/lifecycle_event_log.py` — uses `fcntl.flock()` +- `src/iai_mcp/lifecycle.py` — uses `fcntl.flock()` +- `src/iai_mcp/lock_protocol.py` — uses `fcntl.flock()` +- `src/iai_mcp/doctor/_lifecycle_checks.py` — uses `fcntl.flock()` + +**Fix:** Create `src/iai_mcp/_filelock.py` that provides a `flock(fd, operation)` shim: +- On POSIX: delegates to `fcntl.flock(fd, operation)` +- On Windows: uses `msvcrt.locking()` with appropriate size (use `os.path.getsize` or a large constant like `2**31 - 1`) + +Example shim: +```python +import platform, os +if platform.system() == "Windows": + import msvcrt + LOCK_EX = 1; LOCK_SH = 2; LOCK_UN = 4; LOCK_NB = 8 + def flock(fd, operation): + if isinstance(fd, int): + raw = fd + else: + raw = fd.fileno() + if operation & LOCK_UN: + try: msvcrt.locking(raw, msvcrt.LK_UNLCK, 2**30) + except OSError: pass + elif operation & LOCK_EX: + mode = msvcrt.LK_NBLCK if (operation & LOCK_NB) else msvcrt.LK_LOCK + msvcrt.locking(raw, mode, 2**30) + elif operation & LOCK_SH: + mode = msvcrt.LK_NBLCK if (operation & LOCK_NB) else msvcrt.LK_LOCK + msvcrt.locking(raw, mode, 2**30) +else: + import fcntl as _fcntl + LOCK_EX = _fcntl.LOCK_EX; LOCK_SH = _fcntl.LOCK_SH + LOCK_UN = _fcntl.LOCK_UN; LOCK_NB = _fcntl.LOCK_NB + def flock(fd, operation): + _fcntl.flock(fd, operation) +``` + +Then in each affected file, replace: +```python +import fcntl +... +fcntl.flock(fd, fcntl.LOCK_EX) +``` +with: +```python +from iai_mcp._filelock import flock, LOCK_EX, LOCK_SH, LOCK_UN, LOCK_NB +... +flock(fd, LOCK_EX) +``` + +--- + +### Step 3 — resource module (CRITICAL — daemon crashes on import) + +`resource` is POSIX-only. `src/iai_mcp/daemon/__init__.py` imports it at the top level. + +Files to fix: +- `src/iai_mcp/daemon/__init__.py` — `resource.getrlimit()`, `resource.setrlimit()` + +**Fix:** Wrap in a platform guard: +```python +import platform as _platform +if _platform.system() != "Windows": + import resource as _resource + def _raise_fd_limit(): + soft, hard = _resource.getrlimit(_resource.RLIMIT_NOFILE) + if soft < 4096: + _resource.setrlimit(_resource.RLIMIT_NOFILE, (min(4096, hard), hard)) +else: + def _raise_fd_limit(): + pass # Windows manages FD limits via OS handles +``` + +Also fix in bench files (lower priority, bench-only): +- `bench/memory_footprint.py`, `bench/embed_warm_cost.py`, `bench/consolidation_rss_peak.py`, + `bench/memorygraph_memory.py` — use `psutil.Process(os.getpid()).memory_info().rss` instead + of `resource.getrusage(resource.RUSAGE_SELF).ru_maxrss` + +--- + +### Step 4 — POSIX signals (CRITICAL — daemon crashes on Windows) + +`signal.SIGHUP`, `signal.SIGKILL` do not exist on Windows. + +Files to fix: +- `src/iai_mcp/daemon/__init__.py` — registers SIGHUP handler; calls SIGTERM/SIGKILL +- `src/iai_mcp/daemon/_watchdog.py` — `os.kill(os.getpid(), signal.SIGKILL)` +- `src/iai_mcp/cli/_daemon.py` — `os.kill(pid, signal.SIGTERM)` / `SIGKILL` +- `src/iai_mcp/doctor/__init__.py` — `os.kill(pid, signal.SIGTERM)` + +**Fix:** +```python +import platform, signal, os + +def _terminate_process(pid: int, graceful: bool = True) -> None: + if platform.system() == "Windows": + os.kill(pid, signal.CTRL_C_EVENT) + else: + sig = signal.SIGTERM if graceful else signal.SIGKILL + os.kill(pid, sig) + +# For SIGHUP registration, guard it: +if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, _reload_handler) +``` + +For `os.kill(os.getpid(), signal.SIGKILL)` (self-termination in watchdog), replace with +`sys.exit(1)` on Windows. + +--- + +### Step 5 — Daemon installer: Windows Task Scheduler (MAJOR) + +`iai-mcp daemon install` only supports launchd (macOS) and systemd (Linux). +It needs a Windows backend. + +File: `src/iai_mcp/cli/_daemon.py` + +Add `_is_windows()` guard and implement `cmd_daemon_install_windows()` that: +1. Uses Python's `subprocess` to call `schtasks.exe` — the built-in Windows Task Scheduler CLI. +2. Creates a task that runs `pythonw.exe -m iai_mcp.daemon` at login, hidden. +3. Writes a `WINDOWS_SERVICE_TARGET` path constant analogous to `LAUNCHD_TARGET`. + +Example schtasks command: +``` +schtasks /Create /SC ONLOGON /TN "iai-mcp-daemon" /TR "pythonw.exe -m iai_mcp.daemon" /RL HIGHEST /F +``` + +Also implement `cmd_daemon_uninstall_windows()`: +``` +schtasks /Delete /TN "iai-mcp-daemon" /F +``` + +And `cmd_daemon_start_windows()` / `cmd_daemon_stop_windows()`: +``` +schtasks /Run /TN "iai-mcp-daemon" +taskkill /F /IM pythonw.exe /FI "WINDOWTITLE eq iai-mcp-daemon" +``` + +Wire these into the existing `cmd_daemon_install()` dispatch block alongside the +`_is_macos()` and `_is_linux()` branches. + +--- + +### Step 6 — Shell hooks: PowerShell equivalents (MAJOR) + +Claude Code on Windows does not run `.sh` hook scripts. The three hooks need `.ps1` equivalents. + +Hooks are in `src/iai_mcp/_deploy/hooks/`: +- `iai-mcp-turn-capture.sh` — appends each prompt+response turn to per-session buffer +- `iai-mcp-session-capture.sh` — at session end, rolls the buffer for the daemon +- `iai-mcp-session-recall.sh` — at session start, pipes cached memory prefix to stdout + +**Fix:** Create `.ps1` versions of each that call the Python CLI equivalents: +```powershell +# iai-mcp-turn-capture.ps1 +$python = (Get-Command python).Source +& $python -m iai_mcp capture-turn @args +``` +The Python CLI already has `capture-transcript`, `session-start` subcommands — +the PowerShell hooks just need to call them. + +Also update `src/iai_mcp/cli/_capture.py`'s `cmd_capture_hooks_install()` to: +1. Detect Windows and copy `.ps1` files instead of `.sh` files +2. Patch `~/.claude/settings.json` hooks to reference `.ps1` paths on Windows + +--- + +### Step 7 — os.getuid / pwd module guards (MODERATE) + +`os.getuid()` and the `pwd` module are POSIX-only. + +Files to fix: +- `src/iai_mcp/crypto.py` — `os.geteuid()` at line ~121 +- `src/iai_mcp/cli/_crypto.py` — `st.st_uid == os.geteuid()` at line ~39 +- `src/iai_mcp/hippo/__init__.py` — `pwd.getpwuid(os.getuid()).pw_dir` at line ~54 + +**Fix:** +```python +# For ownership checks: +if hasattr(os, "geteuid") and st.st_uid != os.geteuid(): + raise PermissionError(...) + +# For home directory (hippo/__init__.py): +# Replace pwd.getpwuid(os.getuid()).pw_dir with: +home = str(Path.home()) +``` + +--- + +### Step 8 — Rust build: disable macOS-only features (MODERATE) + +`rust/iai_mcp_embed_core/Cargo.toml` has `accelerate` and `metal` features +(Apple Accelerate framework and Apple Metal GPU). These fail to compile on Windows. + +**Fix:** In `pyproject.toml` (the setuptools-rust build config), add platform-conditional +feature flags. Find the `[[tool.setuptools-rust.ext-modules]]` section and add: + +```toml +[[tool.setuptools-rust.ext-modules]] +target = "iai_mcp_native" +path = "rust/iai_mcp_native/Cargo.toml" +binding = "PyO3" +features = ["extension-module"] +args = ["--no-default-features"] +``` + +This already disables default features. Verify `accelerate` and `metal` are not in the +default feature set of `Cargo.toml`. If they are, add a `[target.'cfg(target_os = "macos")'.dependencies]` +section in `Cargo.toml` to gate them. + +--- + +### Step 9 — Log paths and temp dirs (MINOR) + +`src/iai_mcp/cli/_daemon.py` uses `~/Library/Logs/` for daemon logs (macOS-specific). + +**Fix:** Add `_get_daemon_log_path()`: +```python +import platform +def _get_daemon_log_path() -> Path: + if platform.system() == "Darwin": + return Path.home() / "Library" / "Logs" / "iai-mcp-daemon.stderr.log" + elif platform.system() == "Windows": + return Path(os.environ.get("APPDATA", Path.home())) / "iai-mcp" / "logs" / "daemon.log" + else: + return Path.home() / ".local" / "share" / "iai-mcp" / "logs" / "daemon.log" +``` + +--- + +### Step 10 — chmod security for crypto key (MINOR) + +`src/iai_mcp/crypto.py` calls `os.chmod(key_file, 0o600)` to restrict the encryption key. +On Windows, `chmod` is a no-op for access control. Use `icacls.exe` instead: + +```python +import platform, subprocess +def _secure_key_file(path: Path) -> None: + if platform.system() == "Windows": + user = os.environ.get("USERNAME", "") + subprocess.run( + ["icacls", str(path), "/inheritance:d", "/grant:r", f"{user}:F"], + check=False, capture_output=True, + ) + else: + path.chmod(0o600) +``` + +--- + +## Next Steps (for the next session) + +The core daemon + hook infrastructure is now Windows-ready, and bench files +no longer crash on Windows import. Remaining work: + +1. **Manual testing on Windows:** Verify the port works by: + ```powershell + cd "C:\Users\Daniel Hertz\Documents\GitHub\iai-personal-memory-engine" + python -m venv .venv + .venv\Scripts\activate + pip install -e ".[dev]" + python -m iai_mcp daemon install --dry-run # Check schtasks XML renders + python -m iai_mcp capture-hooks install --dry-run # Check hook paths + ``` + +2. **Update CLAUDE.md:** Add Windows-specific setup notes to the project's CLAUDE.md (if it exists) or create one with: + - Running `iai-mcp daemon install` on Windows (uses Task Scheduler) + - Running `iai-mcp capture-hooks install` on Windows (uses PowerShell hooks) + - Expected log locations (`%APPDATA%\iai-mcp\logs\`) + +## Verification Checklist + +After all steps complete: +- [ ] Daemon imports without crashing on Windows +- [ ] `iai-mcp daemon install` creates a Task Scheduler entry +- [ ] `iai-mcp capture-hooks install` creates PowerShell hooks and registers in settings.json +- [ ] Hook commands reference `.ps1` files (not `.sh`) on Windows in settings.json +- [ ] Logs go to `%APPDATA%\iai-mcp\logs\` (Windows) not `~/.local/share` (Linux) +- [ ] Crypto key file created with appropriate icacls permissions + +## Key Design Decisions + +1. **Platform detection:** Uses `platform.system()` checks (`== "Windows"`, `== "Darwin"`, `== "Linux"`) throughout +2. **File locking:** `_filelock.py` shim normalizes `msvcrt.locking()` (Windows) to `fcntl.flock()` interface (POSIX) +3. **Daemon management:** Task Scheduler on Windows, launchd on macOS, systemd on Linux +4. **Hooks:** Python calls wrapped in shell scripts (.sh on POSIX) or PowerShell scripts (.ps1 on Windows) +5. **No cross-platform abstractions:** Branching logic is explicit per-platform to avoid accidental breakage + +After Step 5 (daemon installer): +```powershell +iai-mcp daemon install +iai-mcp daemon status +``` + +After Step 6 (hooks): +```powershell +iai-mcp capture-hooks install +iai-mcp capture-hooks status +``` + +Full E2E after all steps: +```powershell +iai-mcp doctor +``` + +## Notes + +- The user is on Windows 11 Pro, Python 3.12, Node 18+, has Rust toolchain +- GitHub user: `danielhertz1999-bit`, repo fork is under their account +- The upstream repo is `CodeAbra/iai-personal-memory-engine` +- All changes should be committed to the local `main` branch; a PR to upstream can be opened later +- Keep each step as a separate commit for clean history +- The `setproctitle` module (used in `daemon/__init__.py`) may need a try/except fallback + on Windows if it fails to compile — wrap: `try: from setproctitle import setproctitle\nexcept ImportError: setproctitle = lambda x: None` diff --git a/bench/consolidation_rss_peak.py b/bench/consolidation_rss_peak.py index 5984da7..151d6db 100644 --- a/bench/consolidation_rss_peak.py +++ b/bench/consolidation_rss_peak.py @@ -4,7 +4,6 @@ import gc import json import os -import resource import shutil import sys import tempfile @@ -38,6 +37,14 @@ def _cur_rss_bytes() -> int: def _ru_maxrss_bytes() -> int: + if sys.platform == "win32": + try: + import psutil + mi = psutil.Process().memory_info() + return int(getattr(mi, "peak_wset", mi.rss)) + except Exception: + return 0 + import resource r = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if sys.platform == "darwin": return int(r) diff --git a/bench/embed_warm_cost.py b/bench/embed_warm_cost.py index ce2f086..f5fef0b 100644 --- a/bench/embed_warm_cost.py +++ b/bench/embed_warm_cost.py @@ -58,23 +58,42 @@ """ _PAYLOAD_RSS = r""" -import sys, resource +import sys sys.path.insert(0, {src_path!r}) +import platform as _plat +_system = _plat.system() +if _system == "Windows": + import psutil as _psutil + def _peak_raw(): + mi = _psutil.Process().memory_info() + return int(getattr(mi, "peak_wset", mi.rss)) + def _to_mb(raw): + return raw / 1048576 + _unit_is_bytes = True +else: + import resource as _resource + def _peak_raw(): + return _resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss + if _system == "Darwin": + def _to_mb(raw): + return raw / 1048576 + _unit_is_bytes = True + else: + def _to_mb(raw): + return raw / 1024 + _unit_is_bytes = False from iai_mcp.embed import Embedder e = Embedder() -rss_post_construct_raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss +rss_post_construct_raw = _peak_raw() text = {text!r} _ = e.embed(text) -rss_post_encode_raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss -import platform as _plat -is_mac = (_plat.system() == "Darwin") -def to_mb(raw): - return raw / 1048576 if is_mac else raw / 1024 -print(f"rss_post_construct_mb={{to_mb(rss_post_construct_raw):.1f}}") -print(f"rss_post_encode_mb={{to_mb(rss_post_encode_raw):.1f}}") +rss_post_encode_raw = _peak_raw() +print(f"rss_post_construct_mb={{_to_mb(rss_post_construct_raw):.1f}}") +print(f"rss_post_encode_mb={{_to_mb(rss_post_encode_raw):.1f}}") print(f"rss_post_construct_raw={{rss_post_construct_raw}}") print(f"rss_post_encode_raw={{rss_post_encode_raw}}") -print(f"unit_is_bytes={{is_mac}}") +print(f"unit_is_bytes={{_unit_is_bytes}}") +print(f"rss_platform={{_system}}") """ @@ -210,10 +229,17 @@ def measure_rss(src_path: str, text: str) -> dict: rss_post_construct_mb = float(kv["rss_post_construct_mb"]) rss_post_encode_mb = float(kv["rss_post_encode_mb"]) unit_is_bytes = kv["unit_is_bytes"] == "True" + rss_platform = kv.get("rss_platform", "") + if rss_platform == "Windows": + unit_label = "bytes (Windows peak_wset)" + elif rss_platform == "Darwin" or (unit_is_bytes and not rss_platform): + unit_label = "bytes (macOS)" + else: + unit_label = "KB (Linux)" print( f" RSS post-construct={rss_post_construct_mb:.1f}MB " f"post-first-encode={rss_post_encode_mb:.1f}MB " - f"unit={'bytes (macOS)' if unit_is_bytes else 'KB (Linux)'}" + f"unit={unit_label}" ) return { "rss_post_construct_mb": rss_post_construct_mb, @@ -221,6 +247,7 @@ def measure_rss(src_path: str, text: str) -> dict: "rss_post_construct_raw": int(kv["rss_post_construct_raw"]), "rss_post_encode_raw": int(kv["rss_post_encode_raw"]), "unit_is_bytes_macos": unit_is_bytes, + "rss_platform": rss_platform, } diff --git a/bench/memory_footprint.py b/bench/memory_footprint.py index ea879df..504ff3a 100644 --- a/bench/memory_footprint.py +++ b/bench/memory_footprint.py @@ -4,7 +4,6 @@ import gc import json import os -import resource import sys import tempfile import time @@ -42,6 +41,11 @@ def _threshold_mb_for_n(n: int) -> float: def _rss_mb() -> float: + if sys.platform == "win32": + import psutil + mi = psutil.Process().memory_info() + return float(getattr(mi, "peak_wset", mi.rss)) / 1024.0 / 1024.0 + import resource r = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if sys.platform == "darwin": return float(r) / 1024.0 / 1024.0 diff --git a/bench/memorygraph_memory.py b/bench/memorygraph_memory.py index 06291be..e8f9c46 100644 --- a/bench/memorygraph_memory.py +++ b/bench/memorygraph_memory.py @@ -2,7 +2,6 @@ import argparse import gc -import resource import sys from pathlib import Path from uuid import uuid4 @@ -14,6 +13,11 @@ def rss_mb() -> float: + if sys.platform == "win32": + import psutil + mi = psutil.Process().memory_info() + return float(getattr(mi, "peak_wset", mi.rss)) / (1024 * 1024) + import resource ru = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if sys.platform == "darwin": return ru / (1024 * 1024) diff --git a/pyproject.toml b/pyproject.toml index eed40c9..be16421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ iai_mcp = [ "_deploy/launchd/*.plist", "_deploy/systemd/*.service", "_deploy/hooks/*.sh", + "_deploy/hooks/*.ps1", "_wrapper/*.js", ] diff --git a/src/iai_mcp/_deploy/hooks/iai-mcp-session-capture.ps1 b/src/iai_mcp/_deploy/hooks/iai-mcp-session-capture.ps1 new file mode 100644 index 0000000..9910b52 --- /dev/null +++ b/src/iai_mcp/_deploy/hooks/iai-mcp-session-capture.ps1 @@ -0,0 +1,146 @@ +# IAI-MCP Stop hook — ambient WRITE-side capture (Windows). +# +# PowerShell equivalent of iai-mcp-session-capture.sh. +# Fires when a Claude Code session ends. Calls `iai-mcp capture-transcript +# --no-spawn` to batch-capture the session transcript. +# Fail-safe: always exits 0. + +$ErrorActionPreference = 'SilentlyContinue' + +try { + $inputText = [Console]::In.ReadToEnd() +} catch { + $inputText = '' +} + +$session_id = '' +$transcript_path = '' +$cwd = '' +try { + $obj = $inputText | ConvertFrom-Json + $session_id = if ($obj.session_id) { $obj.session_id } else { '' } + $transcript_path = if ($obj.transcript_path) { $obj.transcript_path } else { '' } + $cwd = if ($obj.cwd) { $obj.cwd } else { '' } +} catch {} + +# Fallback: locate transcript if the hook payload didn't include its path. +if (-not $transcript_path -and $session_id) { + $projectsDir = Join-Path $env:USERPROFILE '.claude\projects' + if (Test-Path $projectsDir) { + Get-ChildItem -Path $projectsDir -Directory | ForEach-Object { + $candidate = Join-Path $_.FullName "$session_id.jsonl" + if ((Test-Path $candidate) -and -not $transcript_path) { + $transcript_path = $candidate + } + } + } +} + +$logDir = Join-Path $env:USERPROFILE '.iai-mcp\logs' +if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } +$logDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') +$logFile = Join-Path $logDir "capture-$logDate.log" +$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + +Add-Content -Path $logFile -Value "---" -ErrorAction SilentlyContinue +Add-Content -Path $logFile -Value "$ts session=$session_id cwd=$cwd transcript=$transcript_path" -ErrorAction SilentlyContinue + +if (-not $transcript_path -or -not (Test-Path $transcript_path)) { + Add-Content -Path $logFile -Value "$ts skipped: no transcript found" -ErrorAction SilentlyContinue + exit 0 +} + +# Rename the active-writer marker so the drain can see it. +if ($session_id) { + $liveFile = Join-Path $env:USERPROFILE ".iai-mcp\.deferred-captures\$session_id.live.jsonl" + if (Test-Path $liveFile) { + $epoch = [int][double]::Parse((Get-Date -UFormat '%s')) + $newName = "$session_id.live-$epoch.jsonl" + $destDir = Split-Path $liveFile -Parent + Move-Item -Path $liveFile -Destination (Join-Path $destDir $newName) -Force -ErrorAction SilentlyContinue + } + $offsetState = Join-Path $env:USERPROFILE ".iai-mcp\.capture-state\$session_id.offset" + if (Test-Path $offsetState) { Remove-Item -Path $offsetState -Force -ErrorAction SilentlyContinue } +} + +# Find the iai-mcp CLI +$iai_cli = $null + +# 1. Environment variable override +if ($env:IAI_MCP_SESSION_CAPTURE_CLI -and (Test-Path $env:IAI_MCP_SESSION_CAPTURE_CLI)) { + $iai_cli = $env:IAI_MCP_SESSION_CAPTURE_CLI +} + +# 2. Cached CLI path +if (-not $iai_cli) { + $cliCache = Join-Path $env:USERPROFILE '.iai-mcp\.cli-path' + if (Test-Path $cliCache) { + $cached = (Get-Content $cliCache -ErrorAction SilentlyContinue).Trim() + if ($cached -and (Test-Path $cached)) { $iai_cli = $cached } + } +} + +# 3. PATH lookup +if (-not $iai_cli) { + try { + $resolved = (Get-Command iai-mcp -ErrorAction Stop).Source + if ($resolved) { + $iai_cli = $resolved + Set-Content -Path (Join-Path $env:USERPROFILE '.iai-mcp\.cli-path') -Value $iai_cli -ErrorAction SilentlyContinue + } + } catch {} +} + +# 4. Common Windows install locations +if (-not $iai_cli) { + $candidates = @( + (Join-Path $env:USERPROFILE '.local\bin\iai-mcp.exe'), + (Join-Path $env:USERPROFILE 'IAI-MCP\.venv\Scripts\iai-mcp.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Scripts\iai-mcp.exe') + ) + foreach ($c in $candidates) { + if (Test-Path $c) { + $iai_cli = $c + Set-Content -Path (Join-Path $env:USERPROFILE '.iai-mcp\.cli-path') -Value $iai_cli -ErrorAction SilentlyContinue + break + } + } +} + +# 5. Fall back to python -m iai_mcp +if (-not $iai_cli) { + $pyExe = $null + try { $pyExe = (Get-Command python -ErrorAction Stop).Source } catch {} + if ($pyExe) { + $iai_cli = "__python__" + } +} + +if (-not $iai_cli) { + Add-Content -Path $logFile -Value "$ts skipped: iai-mcp CLI not found" -ErrorAction SilentlyContinue + exit 0 +} + +# Run capture with a 30s timeout +try { + if ($iai_cli -eq "__python__") { + $pyExe = (Get-Command python -ErrorAction Stop).Source + $proc = Start-Process -FilePath $pyExe ` + -ArgumentList '-m', 'iai_mcp', 'capture-transcript', '--no-spawn', '--session-id', $session_id, '--max-turns', '100000', $transcript_path ` + -NoNewWindow -PassThru -RedirectStandardOutput (Join-Path $logDir 'capture-stdout.tmp') -RedirectStandardError (Join-Path $logDir 'capture-stderr.tmp') + } else { + $proc = Start-Process -FilePath $iai_cli ` + -ArgumentList 'capture-transcript', '--no-spawn', '--session-id', $session_id, '--max-turns', '100000', $transcript_path ` + -NoNewWindow -PassThru -RedirectStandardOutput (Join-Path $logDir 'capture-stdout.tmp') -RedirectStandardError (Join-Path $logDir 'capture-stderr.tmp') + } + $exited = $proc.WaitForExit(30000) + if (-not $exited) { + try { $proc.Kill() } catch {} + } + $rc = if ($exited) { $proc.ExitCode } else { 124 } +} catch { + $rc = 1 +} + +Add-Content -Path $logFile -Value "$ts rc=$rc" -ErrorAction SilentlyContinue +exit 0 diff --git a/src/iai_mcp/_deploy/hooks/iai-mcp-session-recall.ps1 b/src/iai_mcp/_deploy/hooks/iai-mcp-session-recall.ps1 new file mode 100644 index 0000000..8de2379 --- /dev/null +++ b/src/iai_mcp/_deploy/hooks/iai-mcp-session-recall.ps1 @@ -0,0 +1,143 @@ +# IAI-MCP SessionStart hook — recall injection (Windows). +# +# PowerShell equivalent of iai-mcp-session-recall.sh. +# Fires on Claude Code session start. Prints the cached session prefix +# to stdout for Claude Code to inject as additionalContext. +# Fail-safe: always exits 0 with empty stdout on any error. + +$ErrorActionPreference = 'SilentlyContinue' + +try { + $inputText = [Console]::In.ReadToEnd() +} catch { + $inputText = '' +} + +$session_id = '' +$source_evt = '' +try { + $obj = $inputText | ConvertFrom-Json + $session_id = if ($obj.session_id) { $obj.session_id } else { '' } + $source_evt = if ($obj.source) { $obj.source } else { '' } +} catch {} + +$logDir = Join-Path $env:USERPROFILE '.iai-mcp\logs' +if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } +$logDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') +$logFile = Join-Path $logDir "recall-$logDate.log" +$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + +Add-Content -Path $logFile -Value "---" -ErrorAction SilentlyContinue +Add-Content -Path $logFile -Value "$ts session=$session_id source=$source_evt" -ErrorAction SilentlyContinue + +# Try the precache file first +$cachePath = Join-Path $env:USERPROFILE '.iai-mcp\.session-start-payload.cached.md' +if ((Test-Path $cachePath) -and (Get-Item $cachePath).Length -gt 0) { + try { + $cacheOut = Get-Content $cachePath -Raw -ErrorAction Stop + if ($cacheOut.Length -gt 10000) { $cacheOut = $cacheOut.Substring(0, 10000) } + if ($cacheOut) { + [Console]::Out.Write($cacheOut) + $cacheAge = [int]((Get-Date) - (Get-Item $cachePath).LastWriteTime).TotalSeconds + Add-Content -Path $logFile -Value "$ts cache-hit age=${cacheAge}s bytes=$($cacheOut.Length)" -ErrorAction SilentlyContinue + exit 0 + } + } catch {} + Add-Content -Path $logFile -Value "$ts cache-miss empty" -ErrorAction SilentlyContinue +} else { + Add-Content -Path $logFile -Value "$ts cache-miss absent" -ErrorAction SilentlyContinue +} + +# Find the iai-mcp CLI +$iai_cli = $null + +if ($env:IAI_MCP_SESSION_RECALL_CLI -and (Test-Path $env:IAI_MCP_SESSION_RECALL_CLI)) { + $iai_cli = $env:IAI_MCP_SESSION_RECALL_CLI +} + +if (-not $iai_cli) { + $cliCache = Join-Path $env:USERPROFILE '.iai-mcp\.cli-path' + if (Test-Path $cliCache) { + $cached = (Get-Content $cliCache -ErrorAction SilentlyContinue).Trim() + if ($cached -and (Test-Path $cached)) { $iai_cli = $cached } + } +} + +if (-not $iai_cli) { + try { + $resolved = (Get-Command iai-mcp -ErrorAction Stop).Source + if ($resolved) { + $iai_cli = $resolved + Set-Content -Path (Join-Path $env:USERPROFILE '.iai-mcp\.cli-path') -Value $iai_cli -ErrorAction SilentlyContinue + } + } catch {} +} + +if (-not $iai_cli) { + $candidates = @( + (Join-Path $env:USERPROFILE '.local\bin\iai-mcp.exe'), + (Join-Path $env:USERPROFILE 'IAI-MCP\.venv\Scripts\iai-mcp.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs\Python\Scripts\iai-mcp.exe') + ) + foreach ($c in $candidates) { + if (Test-Path $c) { + $iai_cli = $c + Set-Content -Path (Join-Path $env:USERPROFILE '.iai-mcp\.cli-path') -Value $iai_cli -ErrorAction SilentlyContinue + break + } + } +} + +$usePythonModule = $false +if (-not $iai_cli) { + try { + $pyExe = (Get-Command python -ErrorAction Stop).Source + $usePythonModule = $true + } catch {} +} + +if (-not $iai_cli -and -not $usePythonModule) { + Add-Content -Path $logFile -Value "$ts skipped: iai-mcp CLI not found" -ErrorAction SilentlyContinue + exit 0 +} + +# Run session-start with a 10s timeout +$hookTimeout = if ($env:IAI_MCP_RECALL_HOOK_TIMEOUT) { [int]$env:IAI_MCP_RECALL_HOOK_TIMEOUT } else { 10 } +$outTmp = Join-Path $logDir 'recall-stdout.tmp' + +try { + if ($usePythonModule) { + $pyExe = (Get-Command python -ErrorAction Stop).Source + $proc = Start-Process -FilePath $pyExe ` + -ArgumentList '-m', 'iai_mcp', 'session-start', '--session-id', $session_id ` + -NoNewWindow -PassThru -RedirectStandardOutput $outTmp -RedirectStandardError (Join-Path $logDir 'recall-stderr.tmp') + } else { + $proc = Start-Process -FilePath $iai_cli ` + -ArgumentList 'session-start', '--session-id', $session_id ` + -NoNewWindow -PassThru -RedirectStandardOutput $outTmp -RedirectStandardError (Join-Path $logDir 'recall-stderr.tmp') + } + $exited = $proc.WaitForExit($hookTimeout * 1000) + if (-not $exited) { + try { $proc.Kill() } catch {} + $rc = 124 + } else { + $rc = $proc.ExitCode + } +} catch { + $rc = 1 +} + +if ($rc -eq 0 -and (Test-Path $outTmp)) { + $out = Get-Content $outTmp -Raw -ErrorAction SilentlyContinue + if ($out) { + [Console]::Out.Write($out) + } + $outLen = if ($out) { $out.Length } else { 0 } +} else { + $outLen = 0 +} + +Remove-Item -Path $outTmp -Force -ErrorAction SilentlyContinue + +Add-Content -Path $logFile -Value "$ts rc=$rc bytes=$outLen" -ErrorAction SilentlyContinue +exit 0 diff --git a/src/iai_mcp/_deploy/hooks/iai-mcp-turn-capture.ps1 b/src/iai_mcp/_deploy/hooks/iai-mcp-turn-capture.ps1 new file mode 100644 index 0000000..a830aa5 --- /dev/null +++ b/src/iai_mcp/_deploy/hooks/iai-mcp-turn-capture.ps1 @@ -0,0 +1,65 @@ +# IAI-MCP UserPromptSubmit hook — per-turn ambient capture (Windows). +# +# PowerShell equivalent of iai-mcp-turn-capture.sh. +# Reads stdin JSON, extracts session_id + transcript_path, runs inline +# Python for low-latency capture. Fail-safe: always exits 0. + +$ErrorActionPreference = 'SilentlyContinue' + +try { + $inputText = [Console]::In.ReadToEnd() +} catch { + $inputText = '' +} + +$session_id = '' +$transcript_path = '' +try { + $obj = $inputText | ConvertFrom-Json + $session_id = if ($obj.session_id) { $obj.session_id } else { '' } + $transcript_path = if ($obj.transcript_path) { $obj.transcript_path } else { '' } +} catch {} + +$logDir = Join-Path $env:USERPROFILE '.iai-mcp\logs' +if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } +$logDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') +$logFile = Join-Path $logDir "turn-capture-$logDate.log" +$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + +if (-not $session_id -or -not $transcript_path) { + Add-Content -Path $logFile -Value "$ts skipped: missing session_id or transcript_path" -ErrorAction SilentlyContinue + exit 0 +} + +# Find python +$pyExe = $null +try { $pyExe = (Get-Command python -ErrorAction Stop).Source } catch {} +if (-not $pyExe) { + try { $pyExe = (Get-Command python3 -ErrorAction Stop).Source } catch {} +} +if (-not $pyExe) { + # Check common venv location + $venvPy = Join-Path $env:USERPROFILE '.iai-mcp\.venv\Scripts\python.exe' + if (Test-Path $venvPy) { $pyExe = $venvPy } +} +if (-not $pyExe) { + Add-Content -Path $logFile -Value "$ts skipped: python not found" -ErrorAction SilentlyContinue + exit 0 +} + +# Run the Python CLI for turn capture with a 5s timeout +try { + $proc = Start-Process -FilePath $pyExe ` + -ArgumentList '-m', 'iai_mcp', 'capture-turn-deferred', '--session-id', $session_id, '--transcript-path', $transcript_path ` + -NoNewWindow -PassThru -RedirectStandardError (Join-Path $logDir 'turn-capture-stderr.tmp') + $exited = $proc.WaitForExit(5000) + if (-not $exited) { + try { $proc.Kill() } catch {} + } + $rc = if ($exited) { $proc.ExitCode } else { 124 } +} catch { + $rc = 1 +} + +Add-Content -Path $logFile -Value "$ts session=$session_id rc=$rc" -ErrorAction SilentlyContinue +exit 0 diff --git a/src/iai_mcp/_filelock.py b/src/iai_mcp/_filelock.py new file mode 100644 index 0000000..1b94877 --- /dev/null +++ b/src/iai_mcp/_filelock.py @@ -0,0 +1,57 @@ +"""Platform-agnostic file locking shim. + +On POSIX: thin wrapper around fcntl.flock. +On Windows: msvcrt.locking with errno normalisation so callers checking +errno.EWOULDBLOCK / errno.EAGAIN on non-blocking failures work unchanged. +""" +from __future__ import annotations + +import os +import platform + +if platform.system() == "Windows": + import errno as _errno + import msvcrt as _msvcrt + + LOCK_SH = 1 + LOCK_EX = 2 + LOCK_NB = 4 + LOCK_UN = 8 + + def flock(fd: int, operation: int) -> None: + if not isinstance(fd, int): + fd = fd.fileno() + # msvcrt.locking locks bytes from the current file position. + # Do NOT seek here: callers that need a specific offset already seek + # themselves, and fcntl.flock (POSIX) never moves the offset — matching + # that behaviour avoids surprising callers that read/write after locking. + if operation & LOCK_UN: + try: + _msvcrt.locking(fd, _msvcrt.LK_UNLCK, 2**30) + except OSError: + pass + elif operation & (LOCK_EX | LOCK_SH): + if operation & LOCK_NB: + try: + _msvcrt.locking(fd, _msvcrt.LK_NBLCK, 2**30) + except OSError: + raise OSError( + _errno.EWOULDBLOCK, "resource temporarily unavailable" + ) + else: + # LK_LOCK retries for ~10 s then raises OSError. + _msvcrt.locking(fd, _msvcrt.LK_LOCK, 2**30) + +else: + import fcntl as _fcntl + + LOCK_SH = _fcntl.LOCK_SH + LOCK_EX = _fcntl.LOCK_EX + LOCK_NB = _fcntl.LOCK_NB + LOCK_UN = _fcntl.LOCK_UN + + def flock(fd: int, operation: int) -> None: + _fcntl.flock(fd, operation) + + +__all__ = ["flock", "LOCK_EX", "LOCK_NB", "LOCK_SH", "LOCK_UN"] diff --git a/src/iai_mcp/_ipc.py b/src/iai_mcp/_ipc.py new file mode 100644 index 0000000..59ed1a1 --- /dev/null +++ b/src/iai_mcp/_ipc.py @@ -0,0 +1,198 @@ +""" +Platform-agnostic IPC transport layer. + +POSIX: Unix-domain socket → ~/.iai-mcp/.daemon.sock +Windows: TCP loopback → 127.0.0.1: + Port is persisted in ~/.iai-mcp/.daemon.port so clients can find it. +""" +from __future__ import annotations + +import asyncio +import inspect +import os +import platform +import socket +from pathlib import Path +from typing import Any + +IS_WINDOWS: bool = platform.system() == "Windows" + +_BASE_DIR: Path = Path.home() / ".iai-mcp" +SOCKET_PATH: Path = _BASE_DIR / ".daemon.sock" # POSIX only — kept for compatibility +PORT_FILE: Path = _BASE_DIR / ".daemon.port" # Windows only + + +# --------------------------------------------------------------------------- +# Port file helpers (Windows only) +# --------------------------------------------------------------------------- + +def _read_port() -> int | None: + try: + return int(PORT_FILE.read_text().strip()) + except (FileNotFoundError, ValueError, OSError): + return None + + +def _write_port(port: int) -> None: + PORT_FILE.parent.mkdir(parents=True, exist_ok=True) + PORT_FILE.write_text(str(port)) + + +def _remove_port_file() -> None: + try: + PORT_FILE.unlink() + except (FileNotFoundError, OSError): + pass + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + +def ipc_address() -> str | tuple[str, int]: + """ + Return the current IPC endpoint. + POSIX: Unix socket path string. + Windows: ("127.0.0.1", port) tuple. + """ + if not IS_WINDOWS: + env = os.environ.get("IAI_DAEMON_SOCKET_PATH") + return env if env else str(SOCKET_PATH) + port = _read_port() + if port is None: + raise FileNotFoundError( + "Daemon not running: ~/.iai-mcp/.daemon.port not found." + ) + return ("127.0.0.1", port) + + +async def open_ipc_connection( + addr: str | tuple[str, int] | None = None, + *, + timeout: float | None = None, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """ + Open a client connection to the daemon. + + On POSIX wraps asyncio.open_unix_connection; on Windows wraps + asyncio.open_connection over TCP loopback. + + The *addr* parameter is ignored on Windows (always uses port file). + """ + coro: Any + if IS_WINDOWS: + port = _read_port() + if port is None: + raise FileNotFoundError( + "Daemon not running: ~/.iai-mcp/.daemon.port not found." + ) + coro = asyncio.open_connection("127.0.0.1", port) + else: + if addr is None: + env = os.environ.get("IAI_DAEMON_SOCKET_PATH") + addr = env if env else str(SOCKET_PATH) + coro = asyncio.open_unix_connection(str(addr)) + + if timeout is not None: + return await asyncio.wait_for(coro, timeout=timeout) + return await coro + + +async def start_ipc_server( + handler: Any, + addr: str | Path | None = None, +) -> tuple[asyncio.AbstractServer, str | tuple[str, int], bool]: + """ + Start the daemon server. + + Returns ``(server, actual_addr, needs_manual_cleanup)`` where: + - *actual_addr* is the socket path (POSIX) or ("127.0.0.1", port) (Windows). + - *needs_manual_cleanup* is True if the caller must call ``shutdown_ipc`` + in its finally block (i.e. asyncio will NOT clean up automatically). + + On Windows the port is written to PORT_FILE immediately after bind. + """ + if IS_WINDOWS: + server = await asyncio.start_server(handler, "127.0.0.1", 0) + port: int = server.sockets[0].getsockname()[1] + _write_port(port) + return server, ("127.0.0.1", port), True + + # POSIX: try to use asyncio's built-in cleanup_socket (Python 3.12+) + if addr is None: + env = os.environ.get("IAI_DAEMON_SOCKET_PATH") + path_str = env if env else str(SOCKET_PATH) + else: + path_str = str(addr) + + sig = inspect.signature(asyncio.start_unix_server) + supports_cleanup = "cleanup_socket" in sig.parameters + kwargs: dict[str, Any] = {"cleanup_socket": True} if supports_cleanup else {} + + server = await asyncio.start_unix_server(handler, path=path_str, **kwargs) + return server, path_str, not supports_cleanup + + +def cleanup_ipc_address(addr: str | Path | None = None) -> None: + """ + Remove a stale socket file before binding (POSIX only). No-op on Windows. + """ + if IS_WINDOWS: + return + if addr is None: + env = os.environ.get("IAI_DAEMON_SOCKET_PATH") + path = Path(env) if env else SOCKET_PATH + else: + path = Path(addr) + try: + path.unlink() + except FileNotFoundError: + pass + except OSError: + try: + path.unlink() + except OSError: + pass + + +def shutdown_ipc(addr: str | tuple[str, int] | None = None) -> None: + """ + Clean up after daemon shutdown. + POSIX: unlink the socket file (idempotent). + Windows: remove the port file. + """ + if IS_WINDOWS: + _remove_port_file() + return + if addr is None or isinstance(addr, tuple): + env = os.environ.get("IAI_DAEMON_SOCKET_PATH") + path = Path(env) if env else SOCKET_PATH + else: + path = Path(addr) + try: + path.unlink() + except (FileNotFoundError, OSError): + pass + + +def make_sync_ipc_socket() -> tuple[socket.socket, str | tuple[str, int]]: + """ + Create a synchronous (blocking) client socket and the address to connect to. + + Returns ``(sock, addr)`` where *addr* is a string path (POSIX) or + ``("127.0.0.1", port)`` tuple (Windows). Caller is responsible for + ``settimeout``, ``connect``, and ``close``. + """ + if IS_WINDOWS: + port = _read_port() + if port is None: + raise FileNotFoundError( + "Daemon not running: ~/.iai-mcp/.daemon.port not found." + ) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + return s, ("127.0.0.1", port) + + env = os.environ.get("IAI_DAEMON_SOCKET_PATH") + path = env if env else str(SOCKET_PATH) + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + return s, path diff --git a/src/iai_mcp/capture_queue.py b/src/iai_mcp/capture_queue.py index 3c601af..b157f7f 100644 --- a/src/iai_mcp/capture_queue.py +++ b/src/iai_mcp/capture_queue.py @@ -1,11 +1,12 @@ from __future__ import annotations import errno -import fcntl import json import os import secrets import threading + +from iai_mcp._filelock import LOCK_EX, LOCK_NB, LOCK_UN, flock import time from collections.abc import Callable from datetime import datetime, timezone @@ -184,7 +185,7 @@ def ingest_pending(self, handler: Callable[[dict], None]) -> int: try: try: - fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + flock(lock_fd, LOCK_EX | LOCK_NB) except OSError as exc: if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN): continue @@ -211,7 +212,7 @@ def ingest_pending(self, handler: Callable[[dict], None]) -> int: ingested += 1 finally: try: - fcntl.flock(lock_fd, fcntl.LOCK_UN) + flock(lock_fd, LOCK_UN) except OSError: pass os.close(lock_fd) @@ -300,12 +301,12 @@ def _audit_drop( return try: try: - fcntl.flock(fd, fcntl.LOCK_EX) + flock(fd, LOCK_EX) os.write(fd, line.encode("utf-8")) os.fsync(fd) finally: try: - fcntl.flock(fd, fcntl.LOCK_UN) + flock(fd, LOCK_UN) except OSError: pass finally: diff --git a/src/iai_mcp/cli/__init__.py b/src/iai_mcp/cli/__init__.py index ced949c..d4558b3 100644 --- a/src/iai_mcp/cli/__init__.py +++ b/src/iai_mcp/cli/__init__.py @@ -23,6 +23,7 @@ DAEMON_LABEL: str = "com.iai-mcp.daemon" SERVICE_NAME: str = "iai-mcp-daemon.service" +SCHTASKS_TASK_NAME: str = "iai-mcp-daemon" CONSENT_BANNER: str = """\ ============================================================================== @@ -56,6 +57,10 @@ def _is_linux() -> bool: return platform.system() == "Linux" +def _is_windows() -> bool: + return platform.system() == "Windows" + + def _ensure_crypto_key_present(): if os.environ.get("IAI_MCP_CRYPTO_PASSPHRASE"): return None @@ -72,15 +77,16 @@ def _ensure_crypto_key_present(): def _try_short_timeout_connect(timeout_ms: int = 250) -> bool: - import socket as _socket - - sock_path = os.environ.get("IAI_DAEMON_SOCKET_PATH") or str(SOCKET_PATH) - s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + from iai_mcp._ipc import make_sync_ipc_socket + try: + s, addr = make_sync_ipc_socket() + except (FileNotFoundError, OSError): + return False s.settimeout(timeout_ms / 1000.0) try: - s.connect(sock_path) + s.connect(addr) return True - except (FileNotFoundError, ConnectionRefusedError, OSError, _socket.timeout): + except (FileNotFoundError, ConnectionRefusedError, OSError): return False finally: try: @@ -99,18 +105,14 @@ def _send_jsonrpc_request( read_timeout: float = 30.0, ) -> dict | None: import asyncio + from iai_mcp._ipc import open_ipc_connection from iai_mcp.cli._capture import _is_custom_store as _isc if not os.environ.get("IAI_DAEMON_SOCKET_PATH") and _isc(): return None - sock_path = os.environ.get("IAI_DAEMON_SOCKET_PATH") or str(SOCKET_PATH) - async def _runner() -> dict | None: try: - reader, writer = await asyncio.wait_for( - asyncio.open_unix_connection(sock_path), - timeout=connect_timeout, - ) + reader, writer = await open_ipc_connection(timeout=connect_timeout) except (FileNotFoundError, ConnectionRefusedError, OSError, asyncio.TimeoutError): return None try: @@ -142,17 +144,12 @@ async def _runner() -> dict | None: def _send_socket_request(req: dict, *, timeout: float = 30.0) -> dict | None: import asyncio + from iai_mcp._ipc import open_ipc_connection async def _runner() -> dict | None: - _sock = os.environ.get("IAI_DAEMON_SOCKET_PATH") or str(SOCKET_PATH) try: - reader, writer = await asyncio.wait_for( - asyncio.open_unix_connection(_sock), - timeout=5.0, - ) - except (FileNotFoundError, ConnectionRefusedError): - return None - except OSError: + reader, writer = await open_ipc_connection(timeout=5.0) + except (FileNotFoundError, ConnectionRefusedError, OSError): return None try: writer.write((json.dumps(req) + "\n").encode("utf-8")) @@ -323,6 +320,7 @@ def _maintenance_compact_metrics( _launchd_template, _render_launchd_plist, _render_systemd_unit, + _render_schtasks_xml, _prompt_consent, _record_consent_receipt, _remove_state_files, @@ -604,14 +602,17 @@ def _build_parser() -> argparse.ArgumentParser: di = daemon_sub.add_parser( "install", help=( - "install launchd plist (macOS) / systemd user unit (Linux); " - "first-run consent banner unless --yes" + "install launchd plist (macOS) / systemd user unit (Linux) / " + "Task Scheduler job (Windows); first-run consent banner unless --yes" ), ) di.add_argument( "--dry-run", action="store_true", - help="print plist/unit contents without writing or invoking launchctl/systemctl", + help=( + "print service definition (plist / unit / schtasks XML) without " + "writing or invoking launchctl/systemctl/schtasks" + ), ) di.add_argument( "--yes", "-y", @@ -622,17 +623,19 @@ def _build_parser() -> argparse.ArgumentParser: du = daemon_sub.add_parser( "uninstall", - help="C4 clean uninstall: remove plist/unit + 3 state files", + help="C4 clean uninstall: remove plist/unit/scheduled task + 3 state files", ) du.add_argument("--yes", "-y", action="store_true") du.set_defaults(func=cmd_daemon_uninstall) daemon_sub.add_parser( - "start", help="launchctl kickstart / systemctl --user start", + "start", + help="launchctl kickstart / systemctl --user start / schtasks /Run", ).set_defaults(func=cmd_daemon_start) daemon_sub.add_parser( - "stop", help="launchctl kill SIGTERM / systemctl --user stop", + "stop", + help="launchctl kill SIGTERM / systemctl --user stop / schtasks /End", ).set_defaults(func=cmd_daemon_stop) daemon_sub.add_parser( @@ -645,7 +648,10 @@ def _build_parser() -> argparse.ArgumentParser: dlogs = daemon_sub.add_parser( "logs", - help="tail daemon log file (macOS Library/Logs) or journalctl (Linux)", + help=( + "tail daemon log file (macOS Library/Logs, " + "Linux journalctl, Windows %%APPDATA%%\\iai-mcp\\logs)" + ), ) dlogs.add_argument("-f", "--follow", action="store_true") dlogs.add_argument("-n", "--lines", type=int, default=50) diff --git a/src/iai_mcp/cli/_capture.py b/src/iai_mcp/cli/_capture.py index 37f23d7..82d4382 100644 --- a/src/iai_mcp/cli/_capture.py +++ b/src/iai_mcp/cli/_capture.py @@ -323,15 +323,17 @@ def cmd_capture_turn_deferred(args: argparse.Namespace) -> int: def _capture_hook_paths() -> tuple: - src = _res.files("iai_mcp") / "_deploy" / "hooks" / "iai-mcp-session-capture.sh" - dst = Path.home() / ".claude" / "hooks" / "iai-mcp-session-capture.sh" + ext = _hook_ext() + src = _res.files("iai_mcp") / "_deploy" / "hooks" / f"iai-mcp-session-capture{ext}" + dst = Path.home() / ".claude" / "hooks" / f"iai-mcp-session-capture{ext}" settings = Path.home() / ".claude" / "settings.json" return src, dst, settings def _turn_hook_paths() -> tuple: - src = _res.files("iai_mcp") / "_deploy" / "hooks" / "iai-mcp-turn-capture.sh" - dst = Path.home() / ".claude" / "hooks" / "iai-mcp-turn-capture.sh" + ext = _hook_ext() + src = _res.files("iai_mcp") / "_deploy" / "hooks" / f"iai-mcp-turn-capture{ext}" + dst = Path.home() / ".claude" / "hooks" / f"iai-mcp-turn-capture{ext}" return src, dst @@ -399,7 +401,14 @@ def _patch_claude_desktop_config(action: str) -> str: if action == "uninstall": return f"Claude Desktop: {cfg_path} absent — skipped" cfg_path.parent.mkdir(parents=True, exist_ok=True) - data = {"mcpServers": {"iai-mcp": _build_iai_mcp_server_entry()}} + try: + entry = _build_iai_mcp_server_entry() + except FileNotFoundError: + return ( + "Claude Desktop: MCP wrapper not built yet — skipped. " + "Run: cd mcp-wrapper && npm ci && npm run build" + ) + data = {"mcpServers": {"iai-mcp": entry}} cfg_path.write_text(_json.dumps(data, indent=2)) return f"Claude Desktop: created {cfg_path} with iai-mcp registered" @@ -417,7 +426,13 @@ def _patch_claude_desktop_config(action: str) -> str: return f"Claude Desktop: removed iai-mcp from {cfg_path}" return f"Claude Desktop: iai-mcp not in config — no change" - new_entry = _build_iai_mcp_server_entry() + try: + new_entry = _build_iai_mcp_server_entry() + except FileNotFoundError: + return ( + "Claude Desktop: MCP wrapper not built yet — skipped. " + "Run: cd mcp-wrapper && npm ci && npm run build" + ) if servers.get("iai-mcp") == new_entry: return f"Claude Desktop: {cfg_path} already has iai-mcp — no change" servers["iai-mcp"] = new_entry @@ -486,14 +501,21 @@ def _patch_claude_code_config(action: str) -> str: return "Claude Code: patched ~/.claude.json (iai-mcp registered)" -_CAPTURE_HOOK_MARKER = "iai-mcp-session-capture.sh" -_TURN_HOOK_MARKER = "iai-mcp-turn-capture.sh" -_SESSION_RECALL_HOOK_MARKER = "iai-mcp-session-recall.sh" +import platform as _platform + +_CAPTURE_HOOK_MARKER = "iai-mcp-session-capture" +_TURN_HOOK_MARKER = "iai-mcp-turn-capture" +_SESSION_RECALL_HOOK_MARKER = "iai-mcp-session-recall" + + +def _hook_ext() -> str: + return ".ps1" if _platform.system() == "Windows" else ".sh" def _session_recall_hook_paths() -> tuple: - src = _res.files("iai_mcp") / "_deploy" / "hooks" / "iai-mcp-session-recall.sh" - dst = Path.home() / ".claude" / "hooks" / "iai-mcp-session-recall.sh" + ext = _hook_ext() + src = _res.files("iai_mcp") / "_deploy" / "hooks" / f"iai-mcp-session-recall{ext}" + dst = Path.home() / ".claude" / "hooks" / f"iai-mcp-session-recall{ext}" settings = Path.home() / ".claude" / "settings.json" return src, dst, settings @@ -525,12 +547,14 @@ def cmd_capture_hooks_install(args: argparse.Namespace) -> int: dst.parent.mkdir(parents=True, exist_ok=True) dst.write_bytes(src.read_bytes()) - dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) + if hasattr(os, "chmod") and _platform.system() != "Windows": + dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) print(f"installed: {dst}") turn_dst.parent.mkdir(parents=True, exist_ok=True) turn_dst.write_bytes(turn_src.read_bytes()) - turn_dst.chmod(turn_dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) + if hasattr(os, "chmod") and _platform.system() != "Windows": + turn_dst.chmod(turn_dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) print(f"installed: {turn_dst}") settings.parent.mkdir(parents=True, exist_ok=True) @@ -539,8 +563,12 @@ def cmd_capture_hooks_install(args: argparse.Namespace) -> int: stop_list = data["hooks"].setdefault("Stop", []) submit_list = data["hooks"].setdefault("UserPromptSubmit", []) - hook_cmd = f"bash {dst}" - turn_cmd = f"bash {turn_dst}" + if _platform.system() == "Windows": + hook_cmd = f"powershell -ExecutionPolicy Bypass -File \"{dst}\"" + turn_cmd = f"powershell -ExecutionPolicy Bypass -File \"{turn_dst}\"" + else: + hook_cmd = f"bash {dst}" + turn_cmd = f"bash {turn_dst}" already_stop = any( any(_CAPTURE_HOOK_MARKER in (h.get("command") or "") @@ -568,11 +596,15 @@ def cmd_capture_hooks_install(args: argparse.Namespace) -> int: if src_recall.exists(): dst_recall.parent.mkdir(parents=True, exist_ok=True) dst_recall.write_bytes(src_recall.read_bytes()) - dst_recall.chmod(dst_recall.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) + if hasattr(os, "chmod") and _platform.system() != "Windows": + dst_recall.chmod(dst_recall.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) print(f"installed: {dst_recall}") ss_list = data["hooks"].setdefault("SessionStart", []) - recall_cmd = f"bash {dst_recall}" + if _platform.system() == "Windows": + recall_cmd = f"powershell -ExecutionPolicy Bypass -File \"{dst_recall}\"" + else: + recall_cmd = f"bash {dst_recall}" already_recall = any( any(_SESSION_RECALL_HOOK_MARKER in (h.get("command") or "") for h in (entry.get("hooks") or [])) diff --git a/src/iai_mcp/cli/_crypto.py b/src/iai_mcp/cli/_crypto.py index 3a41997..a6a32bb 100644 --- a/src/iai_mcp/cli/_crypto.py +++ b/src/iai_mcp/cli/_crypto.py @@ -35,8 +35,10 @@ def cmd_crypto_status(args: argparse.Namespace) -> int: length = st.st_size status["mode"] = mode_octal status["mode_secure"] = (st.st_mode & 0o077 == 0) - status["uid"] = st.st_uid - status["uid_matches_process"] = (st.st_uid == _os.geteuid()) + status["uid"] = getattr(st, "st_uid", -1) + status["uid_matches_process"] = ( + hasattr(_os, "geteuid") and st.st_uid == _os.geteuid() + ) status["length_bytes"] = length status["length_valid"] = (length == KEY_BYTES) status["passphrase_fallback_set"] = bool( diff --git a/src/iai_mcp/cli/_daemon.py b/src/iai_mcp/cli/_daemon.py index f9de4d6..ee102d3 100644 --- a/src/iai_mcp/cli/_daemon.py +++ b/src/iai_mcp/cli/_daemon.py @@ -62,6 +62,54 @@ def _render_systemd_unit() -> str: return text +def _find_pythonw() -> str: + exe = Path(sys.executable) + pythonw = exe.parent / "pythonw.exe" + if pythonw.exists(): + return str(pythonw) + return sys.executable + + +def _render_schtasks_xml() -> str: + pythonw = _find_pythonw() + username = os.environ.get("USERNAME", "") + log_dir = Path(os.environ.get("APPDATA", str(Path.home()))) / "iai-mcp" / "logs" + return f"""\ + + + + iai-mcp sleep daemon — background memory consolidation for Claude Code + + + + true + {username} + + + + + {username} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + PT0S + true + + + + {pythonw} + -m iai_mcp.daemon + {log_dir} + + +""" + + def _prompt_consent(stream_out=None) -> bool: from iai_mcp import cli as _cli if stream_out is None: @@ -124,15 +172,63 @@ def cmd_daemon_install(args: argparse.Namespace) -> int: elif _cli._is_linux(): content = _render_systemd_unit() target = _cli.SYSTEMD_TARGET + elif _cli._is_windows(): + content = _render_schtasks_xml() + target = None else: print(f"Unsupported OS: {platform.system()}", file=sys.stderr) return 1 if dry_run: - print(f"# Would install to: {target}") + if target is not None: + print(f"# Would install to: {target}") + else: + print(f"# Would create scheduled task: {_cli.SCHTASKS_TASK_NAME}") print(content) return 0 + _cli._ensure_crypto_key_present() + + if _cli._is_windows(): + import subprocess as _sp + import tempfile as _tmpmod + + log_dir = Path(os.environ.get("APPDATA", str(Path.home()))) / "iai-mcp" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + fd, xml_path = _tmpmod.mkstemp(suffix=".xml", prefix="iai-mcp-task-") + try: + with os.fdopen(fd, "w", encoding="utf-16") as f: + f.write(content) + result = _sp.run( + [ + "schtasks", "/Create", + "/TN", _cli.SCHTASKS_TASK_NAME, + "/XML", xml_path, + "/F", + ], + check=False, capture_output=True, text=True, + ) + if result.returncode != 0: + print( + f"schtasks /Create failed ({result.returncode}): " + f"{result.stderr.strip()}", + file=sys.stderr, + ) + return 1 + finally: + try: + os.unlink(xml_path) + except OSError: + pass + + _sp.run( + ["schtasks", "/Run", "/TN", _cli.SCHTASKS_TASK_NAME], + check=False, capture_output=True, + ) + print(f"Installed scheduled task: {_cli.SCHTASKS_TASK_NAME}") + return 0 + target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content) try: @@ -140,9 +236,7 @@ def cmd_daemon_install(args: argparse.Namespace) -> int: except OSError: pass - _cli._ensure_crypto_key_present() - - uid = os.getuid() + uid = os.getuid() if hasattr(os, "getuid") else 0 if _cli._is_macos(): _cli.subprocess.run( ["launchctl", "bootout", f"gui/{uid}", str(target)], @@ -211,7 +305,7 @@ def cmd_daemon_uninstall(args: argparse.Namespace) -> int: print("Uninstall cancelled.", file=sys.stderr) return 1 - uid = os.getuid() + uid = os.getuid() if hasattr(os, "getuid") else 0 if _cli._is_macos(): if _cli.LAUNCHD_TARGET.exists(): _cli.subprocess.run( @@ -236,6 +330,16 @@ def cmd_daemon_uninstall(args: argparse.Namespace) -> int: ["systemctl", "--user", "daemon-reload"], check=False, capture_output=True, ) + elif _cli._is_windows(): + import subprocess as _sp + _sp.run( + ["schtasks", "/End", "/TN", _cli.SCHTASKS_TASK_NAME], + check=False, capture_output=True, + ) + _sp.run( + ["schtasks", "/Delete", "/TN", _cli.SCHTASKS_TASK_NAME, "/F"], + check=False, capture_output=True, + ) _remove_state_files() print("Daemon uninstalled. State files removed.") @@ -244,7 +348,7 @@ def cmd_daemon_uninstall(args: argparse.Namespace) -> int: def cmd_daemon_start(args: argparse.Namespace) -> int: from iai_mcp import cli as _cli - uid = os.getuid() + uid = os.getuid() if hasattr(os, "getuid") else 0 if _cli._is_macos(): target = _cli.LAUNCHD_TARGET _cli.subprocess.run( @@ -264,6 +368,12 @@ def cmd_daemon_start(args: argparse.Namespace) -> int: ["systemctl", "--user", "start", _cli.SERVICE_NAME], check=False, ) + elif _cli._is_windows(): + import subprocess as _sp + _sp.run( + ["schtasks", "/Run", "/TN", _cli.SCHTASKS_TASK_NAME], + check=False, capture_output=True, + ) else: print(f"Unsupported OS: {platform.system()}", file=sys.stderr) return 1 @@ -284,7 +394,7 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int: except (OSError, ValueError, RuntimeError) as exc: logger.debug("sentinel write failed (non-blocking): %s", exc) - uid = os.getuid() + uid = os.getuid() if hasattr(os, "getuid") else 0 if _cli._is_macos(): from iai_mcp.lifecycle_lock import LifecycleLock, _is_pid_alive @@ -300,8 +410,9 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int: return 0 if _is_pid_alive(pid): + _term_sig = getattr(_signal, "SIGTERM", _signal.SIGINT) try: - os.kill(pid, _signal.SIGTERM) + os.kill(pid, _term_sig) except (ProcessLookupError, PermissionError) as exc: logger.debug("SIGTERM to daemon pid=%d failed: %s", pid, exc) return 0 @@ -314,8 +425,9 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int: _time.sleep(interval) if _is_pid_alive(pid): + _kill_sig = getattr(_signal, "SIGKILL", _term_sig) try: - os.kill(pid, _signal.SIGKILL) + os.kill(pid, _kill_sig) except (ProcessLookupError, PermissionError) as exc: logger.debug("SIGKILL to daemon pid=%d failed: %s", pid, exc) return 0 @@ -324,6 +436,36 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int: ["systemctl", "--user", "stop", _cli.SERVICE_NAME], check=False, ) + elif _cli._is_windows(): + import subprocess as _sp + from iai_mcp.lifecycle_lock import LifecycleLock, _is_pid_alive + + _sp.run( + ["schtasks", "/End", "/TN", _cli.SCHTASKS_TASK_NAME], + check=False, capture_output=True, + ) + + payload = LifecycleLock().read() + pid = payload["pid"] if payload else None + if pid is not None and _is_pid_alive(pid): + try: + os.kill(pid, _signal.SIGINT) + except (ProcessLookupError, PermissionError) as exc: + logger.debug("SIGINT to daemon pid=%d failed: %s", pid, exc) + return 0 + + deadline = _time.monotonic() + _stop_escalation_bound() + interval = _stop_poll_interval() + while _time.monotonic() < deadline: + if not _is_pid_alive(pid): + return 0 + _time.sleep(interval) + + if _is_pid_alive(pid): + _sp.run( + ["taskkill", "/F", "/PID", str(pid)], + check=False, capture_output=True, + ) else: print(f"Unsupported OS: {platform.system()}", file=sys.stderr) return 1 @@ -419,12 +561,20 @@ def cmd_daemon_status(args: argparse.Namespace) -> int: return 0 +def _get_daemon_log_path() -> Path: + if platform.system() == "Darwin": + return Path.home() / "Library" / "Logs" / "iai-mcp-daemon.stderr.log" + if platform.system() == "Windows": + return Path(os.environ.get("APPDATA", str(Path.home()))) / "iai-mcp" / "logs" / "daemon.log" + return Path.home() / ".local" / "share" / "iai-mcp" / "logs" / "daemon.log" + + def cmd_daemon_logs(args: argparse.Namespace) -> int: from iai_mcp import cli as _cli follow = bool(getattr(args, "follow", False)) lines = int(getattr(args, "lines", 50)) if _cli._is_macos(): - path = Path.home() / "Library" / "Logs" / "iai-mcp-daemon.stderr.log" + path = _get_daemon_log_path() argv = ["tail"] if follow: argv.append("-f") @@ -435,6 +585,15 @@ def cmd_daemon_logs(args: argparse.Namespace) -> int: if follow: argv.append("-f") _cli.subprocess.run(argv, check=False) + elif platform.system() == "Windows": + path = _get_daemon_log_path() + if not path.exists(): + print(f"No log file at {path}", file=sys.stderr) + return 1 + with open(path, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + for line in all_lines[-lines:]: + print(line, end="") else: print(f"Unsupported OS: {platform.system()}", file=sys.stderr) return 1 diff --git a/src/iai_mcp/concurrency.py b/src/iai_mcp/concurrency.py index cf74987..f8a80e4 100644 --- a/src/iai_mcp/concurrency.py +++ b/src/iai_mcp/concurrency.py @@ -13,15 +13,8 @@ def cleanup_stale_socket(path: Path = SOCKET_PATH) -> None: - try: - path.unlink() - except FileNotFoundError: - pass - except OSError: - try: - path.unlink() - except OSError: - pass + from iai_mcp._ipc import cleanup_ipc_address + cleanup_ipc_address(path) def _validate_socket_message(req: dict) -> tuple[bool, str | None]: @@ -238,19 +231,11 @@ async def serve_control_socket( dispatcher: Callable[[dict], Awaitable[dict]] | None = None, socket_path: Path = SOCKET_PATH, ) -> None: - cleanup_stale_socket(socket_path) - socket_path.parent.mkdir(parents=True, exist_ok=True) + from iai_mcp._ipc import IS_WINDOWS, cleanup_ipc_address, start_ipc_server, shutdown_ipc - _supports_cleanup_socket = False - try: - import inspect as _inspect - import asyncio as _asyncio_mod - _loop_sig = _inspect.signature( - _asyncio_mod.get_event_loop_policy().new_event_loop().create_unix_server - ) - _supports_cleanup_socket = "cleanup_socket" in _loop_sig.parameters - except (TypeError, ValueError, AttributeError): - _supports_cleanup_socket = False + cleanup_ipc_address(socket_path) + if not IS_WINDOWS: + socket_path.parent.mkdir(parents=True, exist_ok=True) async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: try: @@ -280,23 +265,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> except (OSError, ConnectionError): # noqa: BLE001 -- cleanup is best-effort pass - _server_kwargs = {"cleanup_socket": True} if _supports_cleanup_socket else {} - server = await asyncio.start_unix_server( - handle, path=str(socket_path), **_server_kwargs, - ) - try: - os.chmod(str(socket_path), 0o600) - except OSError: - pass + server, actual_addr, needs_cleanup = await start_ipc_server(handle, socket_path) + if not IS_WINDOWS: + try: + os.chmod(str(socket_path), 0o600) + except OSError: + pass try: async with server: await shutdown.wait() finally: - if not _supports_cleanup_socket: - try: - socket_path.unlink() - except FileNotFoundError: - pass - except OSError: - pass + if needs_cleanup: + shutdown_ipc(actual_addr) diff --git a/src/iai_mcp/core/__init__.py b/src/iai_mcp/core/__init__.py index 3cf4ac3..ffb0f53 100644 --- a/src/iai_mcp/core/__init__.py +++ b/src/iai_mcp/core/__init__.py @@ -937,9 +937,10 @@ async def _send_to_daemon( timeout: float = 30.0, socket_path=None, ) -> dict: + from iai_mcp._ipc import open_ipc_connection path_used = socket_path if socket_path is not None else SOCKET_PATH try: - reader, writer = await asyncio.open_unix_connection(str(path_used)) + reader, writer = await open_ipc_connection(str(path_used)) except (FileNotFoundError, ConnectionRefusedError) as exc: return {"ok": False, "reason": "daemon_not_running", "error": str(exc)} diff --git a/src/iai_mcp/crypto.py b/src/iai_mcp/crypto.py index b82f76b..91c64e3 100644 --- a/src/iai_mcp/crypto.py +++ b/src/iai_mcp/crypto.py @@ -12,12 +12,28 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +import platform as _platform +import subprocess as _subprocess + CIPHERTEXT_PREFIX: str = "iai:enc:v1:" NONCE_BYTES: int = 12 KEY_BYTES: int = 32 PBKDF2_ITERATIONS: int = 600_000 SERVICE_NAME_DEFAULT: str = "iai-mcp" + +def _secure_key_file(path: Path) -> None: + """Restrict file permissions to owner-only. On POSIX uses chmod; on Windows uses icacls.""" + if _platform.system() == "Windows": + user = os.environ.get("USERNAME", "") + if user: + _subprocess.run( + ["icacls", str(path), "/inheritance:d", "/grant:r", f"{user}:F"], + check=False, capture_output=True, + ) + else: + path.chmod(0o600) + _DEFAULT_STORE_ROOT: Path = Path.home() / ".iai-mcp" _KEY_FILE_NAME: str = ".crypto.key" @@ -112,13 +128,13 @@ def _try_file_get(self) -> Optional[bytes]: if not path.exists(): return None st = os.stat(path) - if st.st_mode & 0o077 != 0: + if hasattr(os, "geteuid") and st.st_mode & 0o077 != 0: raise CryptoKeyError( f"crypto key file at {path} has insecure mode " f"0o{st.st_mode & 0o777:03o}; expected 0o600 " f"(run: chmod 0o600 {path})" ) - if st.st_uid != os.geteuid(): + if hasattr(os, "geteuid") and st.st_uid != os.geteuid(): raise CryptoKeyError( f"crypto key file at {path} is owned by uid={st.st_uid}; " f"current process runs as uid={os.geteuid()} (refusing to read)" @@ -144,11 +160,14 @@ def _try_file_set(self, key: bytes) -> None: tmp = final.parent / f"{final.name}.tmp.{os.getpid()}" fd = os.open(str(tmp), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) try: - os.fchmod(fd, 0o600) + if hasattr(os, "fchmod"): + os.fchmod(fd, 0o600) os.write(fd, key) os.fsync(fd) finally: os.close(fd) + if not hasattr(os, "fchmod"): + _secure_key_file(tmp) os.rename(str(tmp), str(final)) diff --git a/src/iai_mcp/daemon/__init__.py b/src/iai_mcp/daemon/__init__.py index 2dc4ae4..ea38b28 100644 --- a/src/iai_mcp/daemon/__init__.py +++ b/src/iai_mcp/daemon/__init__.py @@ -6,7 +6,7 @@ import json import logging import os -import resource +import platform as _platform import signal import sys import threading @@ -113,6 +113,10 @@ def _hippo_health_check_on_boot(store) -> dict[str, int | str]: def _raise_fd_limit() -> None: + if _platform.system() == "Windows": + return + import resource as _resource + try: floor = int( os.environ.get("IAI_MCP_DAEMON_NOFILE_FLOOR", _DAEMON_NOFILE_FLOOR_DEFAULT) @@ -121,18 +125,18 @@ def _raise_fd_limit() -> None: floor = _DAEMON_NOFILE_FLOOR_DEFAULT try: - soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + soft, hard = _resource.getrlimit(_resource.RLIMIT_NOFILE) except (OSError, ValueError): return - effective_hard = hard if hard != resource.RLIM_INFINITY else floor + effective_hard = hard if hard != _resource.RLIM_INFINITY else floor target = min(max(soft, floor), effective_hard) if target <= soft: return try: - resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard)) + _resource.setrlimit(_resource.RLIMIT_NOFILE, (target, hard)) log.debug("daemon_fd_limit_raised soft=%d->%d hard=%d", soft, target, hard) except (OSError, ValueError) as exc: log.debug("daemon_fd_limit_raise failed (non-fatal): %s", exc) @@ -652,10 +656,35 @@ def _set_process_title(title: str = "iai lilli (iai_mcp.daemon)") -> None: pass +def _auto_set_embed_offline() -> None: + """Set IAI_MCP_EMBED_OFFLINE if the bge-small-en-v1.5 model is already cached locally. + + The Rust hf-hub client uses a different TLS stack than Python and may fail to reach + huggingface.co in restricted network environments (e.g., containers with custom CA + certificates). When the model files are already present in the HF cache, setting this + env var tells the Rust embedder to skip the network entirely. + """ + if os.environ.get("IAI_MCP_EMBED_OFFLINE"): + return + import pathlib + + revision = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a" + hf_home = os.environ.get("HF_HOME") or os.environ.get("HUGGINGFACE_HUB_CACHE") + if hf_home: + cache_base = pathlib.Path(hf_home) + else: + cache_base = pathlib.Path.home() / ".cache" / "huggingface" / "hub" + snap = cache_base / "models--BAAI--bge-small-en-v1.5" / "snapshots" / revision + if (snap / "model.safetensors").exists() and (snap / "tokenizer.json").exists(): + os.environ["IAI_MCP_EMBED_OFFLINE"] = "1" + log.debug("bge-small-en-v1.5 found in HF cache — setting IAI_MCP_EMBED_OFFLINE=1") + + async def main() -> int: _set_process_title() _require_native() _raise_fd_limit() + _auto_set_embed_offline() store = await _open_exclusive_store_with_backoff( lambda: MemoryStore( @@ -902,10 +931,15 @@ def _capture_handler(record: dict) -> None: shutdown = asyncio.Event() loop = asyncio.get_running_loop() - for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + _shutdown_sigs = [signal.SIGINT] + if hasattr(signal, "SIGTERM"): + _shutdown_sigs.append(signal.SIGTERM) + if hasattr(signal, "SIGHUP"): + _shutdown_sigs.append(signal.SIGHUP) + for sig in _shutdown_sigs: try: loop.add_signal_handler(sig, shutdown.set) - except (NotImplementedError, RuntimeError): + except (NotImplementedError, RuntimeError, ValueError): pass try: diff --git a/src/iai_mcp/daemon/_watchdog.py b/src/iai_mcp/daemon/_watchdog.py index 4485115..2a47192 100644 --- a/src/iai_mcp/daemon/_watchdog.py +++ b/src/iai_mcp/daemon/_watchdog.py @@ -406,7 +406,10 @@ def _self_kill(reason: str, kind: str) -> None: _write_breadcrumb(line) except Exception: # noqa: BLE001 -- breadcrumb is best-effort ONLY pass - os.kill(os.getpid(), signal.SIGKILL) + if hasattr(signal, "SIGKILL"): + os.kill(os.getpid(), signal.SIGKILL) + else: + sys.exit(1) def _capture_blackbox( @@ -514,10 +517,9 @@ def _load_recovery_timestamps( async def _probe_status_roundtrip(sock_path: str, read_timeout: float) -> bool: + from iai_mcp._ipc import open_ipc_connection try: - reader, writer = await asyncio.wait_for( - asyncio.open_unix_connection(sock_path), timeout=5.0 - ) + reader, writer = await open_ipc_connection(sock_path, timeout=5.0) except (FileNotFoundError, ConnectionRefusedError, OSError): return False except asyncio.TimeoutError: diff --git a/src/iai_mcp/direct_write.py b/src/iai_mcp/direct_write.py index 4a67d9a..25666d5 100644 --- a/src/iai_mcp/direct_write.py +++ b/src/iai_mcp/direct_write.py @@ -116,19 +116,17 @@ def _find_record_by_tag_direct(db: Any, tag: str) -> str | None: def _try_get_embedding_fast(text: str, cue: str) -> list[float] | None: - socket_path = os.environ.get("IAI_DAEMON_SOCKET_PATH") - if socket_path: - try: - import socket as _socket - s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) - s.settimeout(0.1) - s.connect(socket_path) - s.close() - except (OSError, ConnectionRefusedError, FileNotFoundError): - return None - else: + from iai_mcp._ipc import IS_WINDOWS, make_sync_ipc_socket + # On POSIX only proceed when IAI_DAEMON_SOCKET_PATH is explicitly set + if not IS_WINDOWS and not os.environ.get("IAI_DAEMON_SOCKET_PATH"): + return None + try: + s, addr = make_sync_ipc_socket() + s.settimeout(0.1) + s.connect(addr) + s.close() + except (OSError, ConnectionRefusedError, FileNotFoundError): return None - return None diff --git a/src/iai_mcp/doctor/__init__.py b/src/iai_mcp/doctor/__init__.py index a33196f..97e7bf3 100644 --- a/src/iai_mcp/doctor/__init__.py +++ b/src/iai_mcp/doctor/__init__.py @@ -56,11 +56,9 @@ def _resolve_socket_path() -> Path: async def _socket_status_probe(socket_path: Path, timeout: float) -> dict | None: + from iai_mcp._ipc import open_ipc_connection try: - reader, writer = await asyncio.wait_for( - asyncio.open_unix_connection(path=str(socket_path)), - timeout=timeout, - ) + reader, writer = await open_ipc_connection(str(socket_path), timeout=timeout) except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError, OSError): return None try: @@ -307,7 +305,8 @@ def _kill_orphan_cores() -> tuple[bool, str, int]: if "iai_mcp.core" not in cl: continue pid = p.info["pid"] - os.kill(pid, signal.SIGTERM) + _term = getattr(signal, "SIGTERM", signal.SIGINT) + os.kill(pid, _term) killed.append(pid) except (psutil.NoSuchProcess, psutil.AccessDenied): continue diff --git a/src/iai_mcp/doctor/_lifecycle_checks.py b/src/iai_mcp/doctor/_lifecycle_checks.py index 0a589f4..ab6a946 100644 --- a/src/iai_mcp/doctor/_lifecycle_checks.py +++ b/src/iai_mcp/doctor/_lifecycle_checks.py @@ -113,11 +113,9 @@ def check_a_daemon_alive() -> CheckResult: async def _socket_connect_probe(socket_path: Path, timeout: float) -> str | None: + from iai_mcp._ipc import open_ipc_connection try: - reader, writer = await asyncio.wait_for( - asyncio.open_unix_connection(path=str(socket_path)), - timeout=timeout, - ) + reader, writer = await open_ipc_connection(str(socket_path), timeout=timeout) except FileNotFoundError: return "FileNotFoundError" except ConnectionRefusedError: @@ -169,7 +167,8 @@ def check_b_socket_fresh() -> CheckResult: def check_c_lock_healthy() -> CheckResult: import errno as _errno - import fcntl as _fcntl + from iai_mcp._filelock import LOCK_NB, LOCK_SH, LOCK_UN + from iai_mcp._filelock import flock as _flock lock_path = _resolve_hippo_db_path().parent / ".lock" if not lock_path.exists(): @@ -180,10 +179,12 @@ def check_c_lock_healthy() -> CheckResult: ) fd = None try: - fd = os.open(str(lock_path), os.O_RDONLY) + # O_RDWR required on Windows (msvcrt.locking needs write access); + # harmless on POSIX since flock ignores open mode. + fd = os.open(str(lock_path), os.O_RDWR) try: - _fcntl.flock(fd, _fcntl.LOCK_SH | _fcntl.LOCK_NB) - _fcntl.flock(fd, _fcntl.LOCK_UN) + _flock(fd, LOCK_SH | LOCK_NB) + _flock(fd, LOCK_UN) return CheckResult( "(c) lock file healthy", True, @@ -197,7 +198,7 @@ def check_c_lock_healthy() -> CheckResult: f"{lock_path} held (consolidating or recall active — normal)", ) raise - except Exception as e: # noqa: BLE001 — fcntl/OSError/permission all FAIL + except Exception as e: # noqa: BLE001 — flock/OSError/permission all FAIL logger.debug("check_c: store-lock probe failed: %s", e) return CheckResult( "(c) lock file healthy", diff --git a/src/iai_mcp/hippo/__init__.py b/src/iai_mcp/hippo/__init__.py index 280cab4..482a5f3 100644 --- a/src/iai_mcp/hippo/__init__.py +++ b/src/iai_mcp/hippo/__init__.py @@ -3,7 +3,6 @@ import contextlib import enum import errno -import fcntl import logging import os import re diff --git a/src/iai_mcp/hippo/_db.py b/src/iai_mcp/hippo/_db.py index 589426e..f4b6e16 100644 --- a/src/iai_mcp/hippo/_db.py +++ b/src/iai_mcp/hippo/_db.py @@ -4,7 +4,6 @@ import contextlib import errno -import fcntl import logging import os import re @@ -20,6 +19,7 @@ import numpy as np import pyarrow as pa +from iai_mcp._filelock import LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN, flock from iai_mcp.crypto import ( decrypt_field, encrypt_field, @@ -190,7 +190,7 @@ def _acquire_exclusive_lock(self) -> None: ) os.chmod(str(self._lock_path), 0o600) try: - fcntl.flock(base_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + flock(base_fd, LOCK_EX | LOCK_NB) except OSError as exc: os.close(base_fd) if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK): @@ -250,7 +250,7 @@ def _acquire_shared_lock( continue try: - fcntl.flock(base_fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + flock(base_fd, LOCK_SH | LOCK_NB) except OSError as exc: if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK): if time.monotonic() >= deadline: @@ -261,7 +261,7 @@ def _acquire_shared_lock( raise if _intent_path.exists(): - fcntl.flock(base_fd, fcntl.LOCK_UN) + flock(base_fd, LOCK_UN) if time.monotonic() >= deadline: break time.sleep(_SHARED_RETRY_SLEEP_S) @@ -277,7 +277,7 @@ def _acquire_shared_lock( with _PROCESS_LOCKS_GUARD: held_sh = _PROCESS_LOCKS_SHARED.get(self._lock_key) if held_sh is not None: - fcntl.flock(base_fd, fcntl.LOCK_UN) + flock(base_fd, LOCK_UN) os.close(base_fd) base_fd2, refcount2 = held_sh self._lock_fd = os.dup(base_fd2) @@ -303,7 +303,7 @@ def downgrade_to_shared(self) -> None: return base_fd, refcount = held try: - fcntl.flock(base_fd, fcntl.LOCK_SH) + flock(base_fd, LOCK_SH) except OSError: return del _PROCESS_LOCKS[self._lock_key] @@ -344,7 +344,7 @@ def escalate_to_exclusive(self, intent_budget_ms: int = 4000) -> None: acquired = False while time.monotonic() < deadline: try: - fcntl.flock(base_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + flock(base_fd, LOCK_EX | LOCK_NB) acquired = True break except OSError as exc: @@ -1015,7 +1015,7 @@ def close(self) -> None: base_fd, refcount = held if refcount <= 1: try: - fcntl.flock(base_fd, fcntl.LOCK_UN) + flock(base_fd, LOCK_UN) except Exception: # noqa: BLE001 pass try: diff --git a/src/iai_mcp/lifecycle.py b/src/iai_mcp/lifecycle.py index a55d68b..29a5e47 100644 --- a/src/iai_mcp/lifecycle.py +++ b/src/iai_mcp/lifecycle.py @@ -2,7 +2,6 @@ import asyncio import errno -import fcntl import os from contextlib import contextmanager from datetime import datetime, timezone @@ -10,6 +9,7 @@ from pathlib import Path from typing import Any, Iterator +from iai_mcp._filelock import LOCK_EX, LOCK_NB, LOCK_UN, flock from iai_mcp.lifecycle_event_log import LifecycleEventLog from iai_mcp.lifecycle_state import ( LIFECYCLE_STATE_PATH, @@ -92,7 +92,7 @@ def _lifecycle_lock(lock_path: Path) -> Iterator[int]: fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o600) try: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + flock(fd, LOCK_EX | LOCK_NB) except OSError as exc: if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK): raise LifecycleStateLocked( @@ -103,7 +103,7 @@ def _lifecycle_lock(lock_path: Path) -> Iterator[int]: yield fd finally: try: - fcntl.flock(fd, fcntl.LOCK_UN) + flock(fd, LOCK_UN) except OSError: pass finally: diff --git a/src/iai_mcp/lifecycle_event_log.py b/src/iai_mcp/lifecycle_event_log.py index 1ee02fd..954c9e7 100644 --- a/src/iai_mcp/lifecycle_event_log.py +++ b/src/iai_mcp/lifecycle_event_log.py @@ -1,12 +1,13 @@ from __future__ import annotations import errno -import fcntl import gzip import json import os import shutil from datetime import datetime, timedelta, timezone + +from iai_mcp._filelock import LOCK_EX, LOCK_UN, flock from pathlib import Path from typing import Any @@ -73,12 +74,12 @@ def append(self, event: dict[str, Any], now: datetime | None = None) -> None: 0o600, ) try: - fcntl.flock(fd, fcntl.LOCK_EX) + flock(fd, LOCK_EX) try: os.write(fd, line.encode("utf-8")) os.fsync(fd) finally: - fcntl.flock(fd, fcntl.LOCK_UN) + flock(fd, LOCK_UN) finally: os.close(fd) diff --git a/src/iai_mcp/lock_protocol.py b/src/iai_mcp/lock_protocol.py index b105406..535eab4 100644 --- a/src/iai_mcp/lock_protocol.py +++ b/src/iai_mcp/lock_protocol.py @@ -1,11 +1,12 @@ from __future__ import annotations import errno -import fcntl import logging import os from pathlib import Path +from iai_mcp._filelock import LOCK_NB, LOCK_SH, flock + logger = logging.getLogger(__name__) @@ -55,7 +56,7 @@ def acquire_client_shared_nb(fd: int, lock_path: Path) -> bool: return False try: - fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + flock(fd, LOCK_SH | LOCK_NB) return True except OSError as exc: if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK): diff --git a/src/iai_mcp/memory_bank.py b/src/iai_mcp/memory_bank.py index e1889c2..b703560 100644 --- a/src/iai_mcp/memory_bank.py +++ b/src/iai_mcp/memory_bank.py @@ -225,7 +225,8 @@ def append_recent_record( 0o600, ) try: - os.fchmod(fd, 0o600) + if hasattr(os, "fchmod"): + os.fchmod(fd, 0o600) os.write(fd, line) os.fsync(fd) finally: diff --git a/src/iai_mcp/semantic_recall.py b/src/iai_mcp/semantic_recall.py index b6bda69..8278e2b 100644 --- a/src/iai_mcp/semantic_recall.py +++ b/src/iai_mcp/semantic_recall.py @@ -66,17 +66,12 @@ def _send_embed_cue_rpc(cue: str, timeout_ms: int) -> "list[float] | None": import asyncio import json - from iai_mcp.concurrency import SOCKET_PATH - - sock_path = os.environ.get("IAI_DAEMON_SOCKET_PATH") or str(SOCKET_PATH) + from iai_mcp._ipc import open_ipc_connection connect_timeout = timeout_ms / 1000.0 async def _runner() -> "list[float] | None": try: - reader, writer = await asyncio.wait_for( - asyncio.open_unix_connection(sock_path), - timeout=connect_timeout, - ) + reader, writer = await open_ipc_connection(timeout=connect_timeout) except (FileNotFoundError, ConnectionRefusedError, OSError, asyncio.TimeoutError): return None try: diff --git a/src/iai_mcp/socket_server.py b/src/iai_mcp/socket_server.py index 31e72ae..74a2d4a 100644 --- a/src/iai_mcp/socket_server.py +++ b/src/iai_mcp/socket_server.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any +from iai_mcp._ipc import IS_WINDOWS, cleanup_ipc_address, open_ipc_connection, shutdown_ipc, start_ipc_server from iai_mcp.concurrency import SOCKET_PATH, cleanup_stale_socket from iai_mcp.core import UnknownMethodError @@ -193,30 +194,32 @@ async def handle( async def serve(self, socket_path: Path | None = None) -> None: + if IS_WINDOWS: + # Windows: TCP server on loopback; socket_path is unused + server, actual_addr, needs_cleanup = await start_ipc_server(self.handle) + try: + async with server: + await self.shutdown_event.wait() + server.close() + await server.wait_closed() + finally: + if needs_cleanup: + shutdown_ipc(actual_addr) + return + if socket_path is None: env_path = os.environ.get("IAI_DAEMON_SOCKET_PATH") socket_path = Path(env_path) if env_path else SOCKET_PATH - sig = inspect.signature(asyncio.start_unix_server) - supports_cleanup_socket = "cleanup_socket" in sig.parameters - inherited = _inherit_activated_socket() if inherited is not None: - server = await asyncio.start_unix_server( - self.handle, - sock=inherited, - ) + server = await asyncio.start_unix_server(self.handle, sock=inherited) + needs_cleanup = False + actual_addr: Any = str(socket_path) else: - cleanup_stale_socket(socket_path) + cleanup_ipc_address(socket_path) socket_path.parent.mkdir(parents=True, exist_ok=True) - server_kwargs: dict[str, Any] = ( - {"cleanup_socket": True} if supports_cleanup_socket else {} - ) - server = await asyncio.start_unix_server( - self.handle, - path=str(socket_path), - **server_kwargs, - ) + server, actual_addr, needs_cleanup = await start_ipc_server(self.handle, socket_path) try: os.chmod(str(socket_path), 0o600) except OSError: @@ -228,8 +231,5 @@ async def serve(self, socket_path: Path | None = None) -> None: server.close() await server.wait_closed() finally: - if inherited is None and not supports_cleanup_socket: - try: - socket_path.unlink() - except (FileNotFoundError, OSError): - pass + if inherited is None and needs_cleanup: + shutdown_ipc(actual_addr) diff --git a/tests/test_capture_queue.py b/tests/test_capture_queue.py index 94875e3..134f913 100644 --- a/tests/test_capture_queue.py +++ b/tests/test_capture_queue.py @@ -1,8 +1,9 @@ from __future__ import annotations import errno -import fcntl import json +from iai_mcp._filelock import LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN +from iai_mcp._filelock import flock as _flock import os import threading import time @@ -171,7 +172,7 @@ def test_idempotent_ingest_lock_skipped(tmp_path): lock_a = tmp_path / f"pending-{ulid_a}.lock" fd = os.open(str(lock_a), os.O_WRONLY | os.O_CREAT, 0o600) try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + _flock(fd, LOCK_EX | LOCK_NB) seen: list[str] = [] @@ -186,7 +187,7 @@ def handler(record: dict) -> None: assert not (tmp_path / f"pending-{ulid_c}.json").exists() finally: try: - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) except OSError: pass os.close(fd) diff --git a/tests/test_daemon_fdlimit_and_fsm.py b/tests/test_daemon_fdlimit_and_fsm.py index 14106d9..9da88d9 100644 --- a/tests/test_daemon_fdlimit_and_fsm.py +++ b/tests/test_daemon_fdlimit_and_fsm.py @@ -1,8 +1,10 @@ from __future__ import annotations import json -import resource import sys + +if sys.platform != "win32": + import resource from pathlib import Path from unittest.mock import MagicMock, patch @@ -13,6 +15,7 @@ from iai_mcp.s2_coordinator import S2Coordinator +@pytest.mark.skipif(sys.platform == "win32", reason="resource module not available on Windows") class TestRaiseFdLimitClampsToHard: def test_raises_low_soft_to_floor(self): diff --git a/tests/test_doctor_lock_probe.py b/tests/test_doctor_lock_probe.py index 18452d7..46dab73 100644 --- a/tests/test_doctor_lock_probe.py +++ b/tests/test_doctor_lock_probe.py @@ -1,8 +1,8 @@ from __future__ import annotations -import fcntl - import pytest +from iai_mcp._filelock import LOCK_EX, LOCK_NB, LOCK_UN +from iai_mcp._filelock import flock as _flock from iai_mcp.doctor import check_c_lock_healthy @@ -46,14 +46,14 @@ def test_held_lock_is_healthy(tmp_store): try: with open(lock_path, "r") as held: held_fd = held.fileno() - fcntl.flock(held_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + _flock(held_fd, LOCK_EX | LOCK_NB) result = check_c_lock_healthy() assert result.passed is True assert result.name == "(c) lock file healthy" assert "held" in result.detail - fcntl.flock(held_fd, fcntl.LOCK_UN) + _flock(held_fd, LOCK_UN) finally: pass diff --git a/tests/test_live_e2e_gate.py b/tests/test_live_e2e_gate.py index 98aef7a..ad8b2b4 100644 --- a/tests/test_live_e2e_gate.py +++ b/tests/test_live_e2e_gate.py @@ -1,8 +1,9 @@ from __future__ import annotations import errno -import fcntl import json +from iai_mcp._filelock import LOCK_NB, LOCK_SH, LOCK_UN +from iai_mcp._filelock import flock as _flock import os import shutil import subprocess @@ -245,8 +246,8 @@ def _ex_held(store_dir: Path) -> bool: probe_fd = -1 try: probe_fd = os.open(str(lock_path), os.O_RDWR) - fcntl.flock(probe_fd, fcntl.LOCK_SH | fcntl.LOCK_NB) - fcntl.flock(probe_fd, fcntl.LOCK_UN) + _flock(probe_fd, LOCK_SH | LOCK_NB) + _flock(probe_fd, LOCK_UN) return False except OSError as exc: if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK): diff --git a/tests/test_lock_starvation.py b/tests/test_lock_starvation.py index 2eb8cc9..e39670e 100644 --- a/tests/test_lock_starvation.py +++ b/tests/test_lock_starvation.py @@ -1,7 +1,8 @@ from __future__ import annotations -import fcntl import os +from iai_mcp._filelock import LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN +from iai_mcp._filelock import flock as _flock import tempfile import threading import time @@ -33,7 +34,7 @@ def _reader_loop() -> None: acquired = acquire_client_shared_nb(fd, lock_path) if acquired: time.sleep(0.001) - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) else: time.sleep(0.001) except Exception as exc: @@ -54,13 +55,13 @@ def _reader_loop() -> None: deadline = time.monotonic() + 4.0 while time.monotonic() < deadline: try: - fcntl.flock(fd_ex, fcntl.LOCK_EX | fcntl.LOCK_NB) + _flock(fd_ex, LOCK_EX | LOCK_NB) acquired = True break except OSError: time.sleep(0.01) if acquired: - fcntl.flock(fd_ex, fcntl.LOCK_UN) + _flock(fd_ex, LOCK_UN) os.close(fd_ex) finally: clear_consolidation_intent(lock_path) @@ -121,10 +122,10 @@ def test_recency_read_during_busy_meets_slo(hermetic_store: Path) -> None: def _hold_exclusive() -> None: fd = os.open(str(lock_path), os.O_RDWR) try: - fcntl.flock(fd, fcntl.LOCK_EX) + _flock(fd, LOCK_EX) ready.set() done.wait(timeout=3.0) - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) finally: os.close(fd) @@ -178,11 +179,11 @@ def _churn_client() -> None: acquired = acquire_client_shared_nb(fd, lock_path) if acquired: if check_consolidation_intent(lock_path): - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) post_acquire_recheck_count += 1 else: time.sleep(0.0005) - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) else: time.sleep(0.001) except Exception as exc: @@ -202,15 +203,15 @@ def _prober_client() -> None: _intent_set.wait(timeout=2.0) try: - fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + _flock(fd, LOCK_SH | LOCK_NB) except OSError: return if check_consolidation_intent(lock_path): - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) post_acquire_recheck_count += 1 else: - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) finally: os.close(fd) @@ -233,13 +234,13 @@ def _prober_client() -> None: deadline = time.monotonic() + 4.0 while time.monotonic() < deadline: try: - fcntl.flock(fd_ex, fcntl.LOCK_EX | fcntl.LOCK_NB) + _flock(fd_ex, LOCK_EX | LOCK_NB) acquired = True break except OSError: time.sleep(0.005) if acquired: - fcntl.flock(fd_ex, fcntl.LOCK_UN) + _flock(fd_ex, LOCK_UN) os.close(fd_ex) finally: clear_consolidation_intent(lock_path) @@ -272,10 +273,10 @@ def test_client_lock_wait_bounded_below_slo(hermetic_store: Path) -> None: def _hold_ex() -> None: fd = os.open(str(lock_path), os.O_RDWR) try: - fcntl.flock(fd, fcntl.LOCK_EX) + _flock(fd, LOCK_EX) ready.set() done.wait(timeout=0.6) - fcntl.flock(fd, fcntl.LOCK_UN) + _flock(fd, LOCK_UN) finally: os.close(fd) @@ -297,7 +298,7 @@ def _hold_ex() -> None: time.sleep(0.01) elapsed = time.monotonic() - t0 if acquired: - fcntl.flock(fd_sh, fcntl.LOCK_UN) + _flock(fd_sh, LOCK_UN) finally: os.close(fd_sh) t.join(timeout=2.0)