wingman-ai 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. share/wingman/node_listener/package-lock.json +1785 -0
  2. share/wingman/node_listener/package.json +50 -0
  3. share/wingman/node_listener/src/index.ts +108 -0
  4. share/wingman/node_listener/src/ipc.ts +70 -0
  5. share/wingman/node_listener/src/messageHandler.ts +135 -0
  6. share/wingman/node_listener/src/socket.ts +244 -0
  7. share/wingman/node_listener/src/types.d.ts +13 -0
  8. share/wingman/node_listener/tsconfig.json +19 -0
  9. wingman/__init__.py +4 -0
  10. wingman/__main__.py +6 -0
  11. wingman/cli/__init__.py +5 -0
  12. wingman/cli/commands/__init__.py +1 -0
  13. wingman/cli/commands/auth.py +90 -0
  14. wingman/cli/commands/config.py +109 -0
  15. wingman/cli/commands/init.py +71 -0
  16. wingman/cli/commands/logs.py +84 -0
  17. wingman/cli/commands/start.py +111 -0
  18. wingman/cli/commands/status.py +84 -0
  19. wingman/cli/commands/stop.py +33 -0
  20. wingman/cli/commands/uninstall.py +113 -0
  21. wingman/cli/main.py +50 -0
  22. wingman/cli/wizard.py +356 -0
  23. wingman/config/__init__.py +31 -0
  24. wingman/config/paths.py +153 -0
  25. wingman/config/personality.py +155 -0
  26. wingman/config/registry.py +343 -0
  27. wingman/config/settings.py +294 -0
  28. wingman/core/__init__.py +16 -0
  29. wingman/core/agent.py +257 -0
  30. wingman/core/ipc_handler.py +124 -0
  31. wingman/core/llm/__init__.py +5 -0
  32. wingman/core/llm/client.py +77 -0
  33. wingman/core/memory/__init__.py +6 -0
  34. wingman/core/memory/context.py +109 -0
  35. wingman/core/memory/models.py +213 -0
  36. wingman/core/message_processor.py +277 -0
  37. wingman/core/policy/__init__.py +5 -0
  38. wingman/core/policy/evaluator.py +265 -0
  39. wingman/core/process_manager.py +135 -0
  40. wingman/core/safety/__init__.py +8 -0
  41. wingman/core/safety/cooldown.py +63 -0
  42. wingman/core/safety/quiet_hours.py +75 -0
  43. wingman/core/safety/rate_limiter.py +58 -0
  44. wingman/core/safety/triggers.py +117 -0
  45. wingman/core/transports/__init__.py +14 -0
  46. wingman/core/transports/base.py +106 -0
  47. wingman/core/transports/imessage/__init__.py +5 -0
  48. wingman/core/transports/imessage/db_listener.py +280 -0
  49. wingman/core/transports/imessage/sender.py +162 -0
  50. wingman/core/transports/imessage/transport.py +140 -0
  51. wingman/core/transports/whatsapp.py +180 -0
  52. wingman/daemon/__init__.py +5 -0
  53. wingman/daemon/manager.py +303 -0
  54. wingman/installer/__init__.py +5 -0
  55. wingman/installer/node_installer.py +253 -0
  56. wingman_ai-1.0.0.dist-info/METADATA +553 -0
  57. wingman_ai-1.0.0.dist-info/RECORD +60 -0
  58. wingman_ai-1.0.0.dist-info/WHEEL +4 -0
  59. wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
  60. wingman_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,140 @@
1
+ """iMessage transport implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from ..base import BaseTransport, MessageEvent, Platform
8
+ from .db_listener import IMessageData, IMessageDBListener
9
+ from .sender import IMessageSender
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class IMessageTransport(BaseTransport):
15
+ """
16
+ iMessage transport using chat.db polling and AppleScript sending.
17
+
18
+ Requirements:
19
+ - macOS only
20
+ - Full Disk Access permission for the Python process
21
+ - Messages.app configured with iMessage account
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ db_path: Path | None = None,
27
+ poll_interval: float = 2.0,
28
+ ):
29
+ super().__init__()
30
+ self._listener = IMessageDBListener(
31
+ db_path=db_path,
32
+ poll_interval=poll_interval,
33
+ )
34
+ self._sender = IMessageSender()
35
+ self._listener_task: asyncio.Task | None = None
36
+
37
+ @property
38
+ def platform(self) -> Platform:
39
+ return Platform.IMESSAGE
40
+
41
+ async def start(self) -> None:
42
+ """Start the iMessage transport."""
43
+ logger.info("Starting iMessage transport...")
44
+ self._running = True
45
+
46
+ # Set up message callback
47
+ self._listener.set_message_callback(self._on_message)
48
+
49
+ # Start listener in background task
50
+ self._listener_task = asyncio.create_task(self._listener.start())
51
+
52
+ logger.info("iMessage transport started")
53
+
54
+ async def _on_message(self, msg: IMessageData) -> None:
55
+ """Handle incoming iMessage from the database listener."""
56
+ # Skip our own messages
57
+ if msg.is_from_me:
58
+ logger.debug(f"Skipping self message: {msg.text[:30]}...")
59
+ return
60
+
61
+ # Convert to MessageEvent
62
+ event = MessageEvent(
63
+ chat_id=msg.chat_id or f"imessage:{msg.handle_id}",
64
+ sender_id=f"imessage:{msg.handle_id}",
65
+ text=msg.text,
66
+ timestamp=msg.timestamp,
67
+ platform=Platform.IMESSAGE,
68
+ sender_name=msg.chat_name if msg.is_group else None,
69
+ is_group=msg.is_group,
70
+ is_self=msg.is_from_me,
71
+ raw_data={
72
+ "rowid": msg.rowid,
73
+ "handle_id": msg.handle_id,
74
+ "chat_id": msg.chat_id,
75
+ "chat_name": msg.chat_name,
76
+ "is_group": msg.is_group,
77
+ },
78
+ )
79
+
80
+ logger.info(
81
+ f"iMessage received: from={msg.handle_id}, "
82
+ f"group={msg.is_group}, text={msg.text[:50]}..."
83
+ )
84
+
85
+ await self._dispatch_message(event)
86
+
87
+ async def stop(self) -> None:
88
+ """Stop the iMessage transport."""
89
+ logger.info("Stopping iMessage transport...")
90
+ self._running = False
91
+
92
+ # Stop the listener
93
+ await self._listener.stop()
94
+
95
+ # Cancel listener task
96
+ if self._listener_task:
97
+ self._listener_task.cancel()
98
+ try:
99
+ await self._listener_task
100
+ except asyncio.CancelledError:
101
+ pass
102
+
103
+ logger.info("iMessage transport stopped")
104
+
105
+ async def send_message(self, chat_id: str, text: str) -> bool:
106
+ """Send an iMessage."""
107
+ # Parse chat_id to determine if group or individual
108
+ # Format: "imessage:+1234567890" or "chat123456789"
109
+
110
+ if chat_id.startswith("imessage:"):
111
+ # Direct message - extract phone/email
112
+ recipient = chat_id.replace("imessage:", "")
113
+ return await self._sender.send_message(
114
+ recipient=recipient,
115
+ text=text,
116
+ is_group=False,
117
+ )
118
+ else:
119
+ # Group chat - use chat_id directly
120
+ # Extract handle from raw_data if available
121
+ return await self._sender.send_message(
122
+ recipient=chat_id,
123
+ text=text,
124
+ is_group=True,
125
+ chat_id=chat_id,
126
+ )
127
+
128
+ async def check_availability(self) -> bool:
129
+ """Check if iMessage is available on this system."""
130
+ # Check if chat.db exists
131
+ if not self._listener._db_path.exists():
132
+ logger.warning("iMessage database not found")
133
+ return False
134
+
135
+ # Check if Messages.app is accessible
136
+ if not await self._sender.check_messages_app():
137
+ logger.warning("Messages.app not accessible")
138
+ return False
139
+
140
+ return True
@@ -0,0 +1,180 @@
1
+ """WhatsApp transport implementation wrapping Node.js IPC."""
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable, Coroutine
6
+ from pathlib import Path
7
+
8
+ from ..ipc_handler import IPCHandler
9
+ from ..process_manager import NodeProcessManager
10
+ from .base import BaseTransport, MessageEvent, Platform
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class WhatsAppTransport(BaseTransport):
16
+ """
17
+ WhatsApp transport using Baileys via Node.js subprocess.
18
+ Wraps the existing Node.js IPC communication.
19
+ """
20
+
21
+ def __init__(self, node_dir: Path, auth_state_dir: Path | None = None):
22
+ super().__init__()
23
+ self._node_manager = NodeProcessManager(node_dir, auth_state_dir)
24
+ self._ipc: IPCHandler | None = None
25
+ self._self_id: str | None = None
26
+
27
+ # Callbacks for WhatsApp-specific events
28
+ self._on_connected: Callable[[str], Coroutine] | None = None
29
+ self._on_disconnected: Callable[[], Coroutine] | None = None
30
+ self._on_qr_code: Callable[[], Coroutine] | None = None
31
+
32
+ @property
33
+ def platform(self) -> Platform:
34
+ return Platform.WHATSAPP
35
+
36
+ @property
37
+ def self_id(self) -> str | None:
38
+ """Get the connected WhatsApp user ID."""
39
+ return self._self_id
40
+
41
+ def set_connected_handler(self, handler: Callable[[str], Coroutine]) -> None:
42
+ """Set callback for when WhatsApp connects."""
43
+ self._on_connected = handler
44
+
45
+ def set_disconnected_handler(self, handler: Callable[[], Coroutine]) -> None:
46
+ """Set callback for when WhatsApp disconnects."""
47
+ self._on_disconnected = handler
48
+
49
+ def set_qr_code_handler(self, handler: Callable[[], Coroutine]) -> None:
50
+ """Set callback for QR code events."""
51
+ self._on_qr_code = handler
52
+
53
+ async def start(self) -> None:
54
+ """Start the WhatsApp transport."""
55
+ logger.info("Starting WhatsApp transport...")
56
+ self._running = True
57
+
58
+ # Start Node.js subprocess
59
+ self._ipc = await self._node_manager.start()
60
+
61
+ # Register IPC handlers
62
+ self._register_ipc_handlers()
63
+
64
+ logger.info("WhatsApp transport started")
65
+
66
+ # Run the IPC loop (blocks until stopped)
67
+ try:
68
+ await self._ipc.start()
69
+ except Exception as e:
70
+ logger.error(f"WhatsApp IPC loop error: {e}")
71
+ self._running = False
72
+ raise
73
+
74
+ def _register_ipc_handlers(self) -> None:
75
+ """Register handlers for Node.js IPC events."""
76
+ if not self._ipc:
77
+ return
78
+
79
+ async def on_message(data: dict) -> None:
80
+ """Handle incoming WhatsApp message."""
81
+ event = self._convert_to_event(data)
82
+ await self._dispatch_message(event)
83
+
84
+ async def on_connected(data: dict) -> None:
85
+ """Handle WhatsApp connection."""
86
+ user = data.get("user", {})
87
+ user_id = user.get("id", "")
88
+ self._self_id = user_id
89
+ logger.info(f"WhatsApp connected: {user_id}")
90
+ if self._on_connected:
91
+ await self._on_connected(user_id)
92
+
93
+ async def on_disconnected(data: dict) -> None:
94
+ """Handle WhatsApp disconnection."""
95
+ logger.warning(f"WhatsApp disconnected: {data}")
96
+ if self._on_disconnected:
97
+ await self._on_disconnected()
98
+
99
+ async def on_qr_code(data: dict) -> None:
100
+ """Handle QR code event."""
101
+ logger.info("QR code received - check terminal")
102
+ if self._on_qr_code:
103
+ await self._on_qr_code()
104
+
105
+ async def on_error(data: dict) -> None:
106
+ """Handle Node.js error."""
107
+ logger.error(f"WhatsApp error: {data.get('message', 'Unknown error')}")
108
+
109
+ async def on_logged_out(data: dict) -> None:
110
+ """Handle logout event."""
111
+ logger.error("Logged out from WhatsApp")
112
+ if self._on_disconnected:
113
+ await self._on_disconnected()
114
+
115
+ async def on_send_result(data: dict) -> None:
116
+ """Handle send result."""
117
+ success = data.get("success", False)
118
+ jid = data.get("jid", "")
119
+ if success:
120
+ logger.debug(f"Message sent to {jid}")
121
+ else:
122
+ logger.error(f"Failed to send message to {jid}")
123
+
124
+ async def on_starting(data: dict) -> None:
125
+ logger.info("Node.js starting...")
126
+
127
+ async def on_pong(data: dict) -> None:
128
+ logger.debug("Pong received")
129
+
130
+ # Register all handlers
131
+ self._ipc.register_handler("message", on_message)
132
+ self._ipc.register_handler("connected", on_connected)
133
+ self._ipc.register_handler("disconnected", on_disconnected)
134
+ self._ipc.register_handler("qr_code", on_qr_code)
135
+ self._ipc.register_handler("error", on_error)
136
+ self._ipc.register_handler("logged_out", on_logged_out)
137
+ self._ipc.register_handler("send_result", on_send_result)
138
+ self._ipc.register_handler("starting", on_starting)
139
+ self._ipc.register_handler("pong", on_pong)
140
+
141
+ def _convert_to_event(self, data: dict) -> MessageEvent:
142
+ """Convert IPC message data to MessageEvent."""
143
+ return MessageEvent(
144
+ chat_id=data.get("chatId", ""),
145
+ sender_id=data.get("senderId", ""),
146
+ text=data.get("text", ""),
147
+ timestamp=data.get("timestamp", time.time()),
148
+ platform=Platform.WHATSAPP,
149
+ sender_name=data.get("senderName"),
150
+ is_group=data.get("isGroup", False),
151
+ is_self=data.get("isSelf", False),
152
+ raw_data=data,
153
+ quoted_message=data.get("quotedMessage"),
154
+ )
155
+
156
+ async def stop(self) -> None:
157
+ """Stop the WhatsApp transport."""
158
+ logger.info("Stopping WhatsApp transport...")
159
+ self._running = False
160
+
161
+ if self._ipc:
162
+ self._ipc.stop()
163
+
164
+ await self._node_manager.stop()
165
+ self._ipc = None
166
+
167
+ logger.info("WhatsApp transport stopped")
168
+
169
+ async def send_message(self, chat_id: str, text: str) -> bool:
170
+ """Send a WhatsApp message."""
171
+ if not self._ipc:
172
+ logger.error("Cannot send message: IPC not connected")
173
+ return False
174
+
175
+ try:
176
+ await self._ipc.send_message(chat_id, text)
177
+ return True
178
+ except Exception as e:
179
+ logger.error(f"Failed to send WhatsApp message: {e}")
180
+ return False
@@ -0,0 +1,5 @@
1
+ """Daemon management module for Wingman."""
2
+
3
+ from .manager import DaemonManager
4
+
5
+ __all__ = ["DaemonManager"]
@@ -0,0 +1,303 @@
1
+ """Daemon manager for Wingman."""
2
+
3
+ import logging
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import time
9
+
10
+ from wingman.config.paths import WingmanPaths
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DaemonManager:
16
+ """
17
+ Manages Wingman as a background daemon.
18
+
19
+ On macOS, uses launchd for proper system integration.
20
+ Falls back to PID file management on other platforms.
21
+ """
22
+
23
+ LAUNCHD_LABEL = "com.wingman.agent"
24
+
25
+ def __init__(self, paths: WingmanPaths):
26
+ self.paths = paths
27
+ self._is_macos = sys.platform == "darwin"
28
+
29
+ def start(self) -> None:
30
+ """Start the daemon."""
31
+ if self._is_macos:
32
+ self._start_launchd()
33
+ else:
34
+ self._start_pidfile()
35
+
36
+ def stop(self) -> None:
37
+ """Stop the daemon."""
38
+ if self._is_macos:
39
+ self._stop_launchd()
40
+ else:
41
+ self._stop_pidfile()
42
+
43
+ def restart(self) -> None:
44
+ """Restart the daemon."""
45
+ self.stop()
46
+ time.sleep(1)
47
+ self.start()
48
+
49
+ def is_running(self) -> bool:
50
+ """Check if the daemon is running."""
51
+ if self._is_macos:
52
+ return self._is_launchd_running()
53
+ else:
54
+ return self._is_pidfile_running()
55
+
56
+ def get_pid(self) -> int | None:
57
+ """Get the PID of the running daemon."""
58
+ if self._is_macos:
59
+ return self._get_launchd_pid()
60
+ else:
61
+ return self._get_pidfile_pid()
62
+
63
+ def get_uptime(self) -> float | None:
64
+ """Get daemon uptime in seconds."""
65
+ pid = self.get_pid()
66
+ if pid is None:
67
+ return None
68
+
69
+ try:
70
+ # Use ps to get process start time
71
+ result = subprocess.run(
72
+ ["ps", "-o", "etime=", "-p", str(pid)], capture_output=True, text=True
73
+ )
74
+ if result.returncode == 0:
75
+ etime = result.stdout.strip()
76
+ return self._parse_etime(etime)
77
+ except Exception:
78
+ pass
79
+
80
+ return None
81
+
82
+ def _parse_etime(self, etime: str) -> float:
83
+ """Parse ps etime format to seconds."""
84
+ # Format: [[DD-]HH:]MM:SS
85
+ parts = etime.replace("-", ":").split(":")
86
+ parts = [int(p) for p in parts]
87
+
88
+ if len(parts) == 2:
89
+ # MM:SS
90
+ return parts[0] * 60 + parts[1]
91
+ elif len(parts) == 3:
92
+ # HH:MM:SS
93
+ return parts[0] * 3600 + parts[1] * 60 + parts[2]
94
+ elif len(parts) == 4:
95
+ # DD:HH:MM:SS
96
+ return parts[0] * 86400 + parts[1] * 3600 + parts[2] * 60 + parts[3]
97
+
98
+ return 0
99
+
100
+ # ========== launchd implementation (macOS) ==========
101
+
102
+ def _get_plist_content(self) -> str:
103
+ """Generate launchd plist content."""
104
+ python_path = sys.executable
105
+ log_file = self.paths.log_dir / "agent.log"
106
+ error_file = self.paths.log_dir / "error.log"
107
+
108
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
109
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
110
+ <plist version="1.0">
111
+ <dict>
112
+ <key>Label</key>
113
+ <string>{self.LAUNCHD_LABEL}</string>
114
+
115
+ <key>ProgramArguments</key>
116
+ <array>
117
+ <string>{python_path}</string>
118
+ <string>-m</string>
119
+ <string>wingman.core.agent</string>
120
+ </array>
121
+
122
+ <key>RunAtLoad</key>
123
+ <false/>
124
+
125
+ <key>KeepAlive</key>
126
+ <dict>
127
+ <key>SuccessfulExit</key>
128
+ <false/>
129
+ </dict>
130
+
131
+ <key>StandardOutPath</key>
132
+ <string>{log_file}</string>
133
+
134
+ <key>StandardErrorPath</key>
135
+ <string>{error_file}</string>
136
+
137
+ <key>WorkingDirectory</key>
138
+ <string>{self.paths.config_dir}</string>
139
+
140
+ <key>EnvironmentVariables</key>
141
+ <dict>
142
+ <key>PATH</key>
143
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
144
+ </dict>
145
+ </dict>
146
+ </plist>
147
+ """
148
+
149
+ def _start_launchd(self) -> None:
150
+ """Start using launchd."""
151
+ plist_path = self.paths.launchd_plist
152
+
153
+ # Ensure log directory exists
154
+ self.paths.log_dir.mkdir(parents=True, exist_ok=True)
155
+
156
+ # Write plist file
157
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
158
+ plist_path.write_text(self._get_plist_content())
159
+
160
+ # Load and start the agent
161
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True)
162
+ subprocess.run(["launchctl", "start", self.LAUNCHD_LABEL], check=True)
163
+
164
+ logger.info(f"Daemon started via launchd: {self.LAUNCHD_LABEL}")
165
+
166
+ def _stop_launchd(self) -> None:
167
+ """Stop using launchd."""
168
+ plist_path = self.paths.launchd_plist
169
+
170
+ # Stop the agent
171
+ subprocess.run(["launchctl", "stop", self.LAUNCHD_LABEL], capture_output=True)
172
+
173
+ # Unload the plist
174
+ if plist_path.exists():
175
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
176
+
177
+ logger.info(f"Daemon stopped: {self.LAUNCHD_LABEL}")
178
+
179
+ def _is_launchd_running(self) -> bool:
180
+ """Check if launchd agent is running."""
181
+ result = subprocess.run(
182
+ ["launchctl", "list", self.LAUNCHD_LABEL], capture_output=True, text=True
183
+ )
184
+ return result.returncode == 0
185
+
186
+ def _get_launchd_pid(self) -> int | None:
187
+ """Get PID from launchd."""
188
+ result = subprocess.run(
189
+ ["launchctl", "list", self.LAUNCHD_LABEL], capture_output=True, text=True
190
+ )
191
+
192
+ if result.returncode != 0:
193
+ return None
194
+
195
+ # Parse output: PID\tStatus\tLabel
196
+ parts = result.stdout.strip().split("\t")
197
+ if len(parts) >= 1 and parts[0] != "-":
198
+ try:
199
+ return int(parts[0])
200
+ except ValueError:
201
+ pass
202
+
203
+ return None
204
+
205
+ # ========== PID file implementation (fallback) ==========
206
+
207
+ def _start_pidfile(self) -> None:
208
+ """Start using PID file (non-macOS fallback)."""
209
+ pid_file = self.paths.pid_file
210
+
211
+ # Check if already running
212
+ if self._is_pidfile_running():
213
+ raise RuntimeError("Daemon is already running")
214
+
215
+ # Fork and run in background
216
+ python_path = sys.executable
217
+
218
+ # Start as subprocess
219
+ process = subprocess.Popen(
220
+ [python_path, "-m", "wingman.core.agent"],
221
+ stdout=open(self.paths.log_dir / "agent.log", "a"),
222
+ stderr=open(self.paths.log_dir / "error.log", "a"),
223
+ cwd=str(self.paths.config_dir),
224
+ start_new_session=True,
225
+ )
226
+
227
+ # Write PID file
228
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
229
+ pid_file.write_text(str(process.pid))
230
+
231
+ logger.info(f"Daemon started with PID: {process.pid}")
232
+
233
+ def _stop_pidfile(self) -> None:
234
+ """Stop using PID file."""
235
+ pid_file = self.paths.pid_file
236
+ pid = self._get_pidfile_pid()
237
+
238
+ if pid is None:
239
+ return
240
+
241
+ # Send SIGTERM
242
+ try:
243
+ os.kill(pid, signal.SIGTERM)
244
+
245
+ # Wait for process to exit
246
+ for _ in range(50): # 5 seconds
247
+ time.sleep(0.1)
248
+ try:
249
+ os.kill(pid, 0) # Check if still running
250
+ except ProcessLookupError:
251
+ break
252
+ else:
253
+ # Force kill if still running
254
+ os.kill(pid, signal.SIGKILL)
255
+
256
+ except ProcessLookupError:
257
+ pass # Process already exited
258
+ except Exception as e:
259
+ logger.error(f"Error stopping daemon: {e}")
260
+
261
+ # Remove PID file
262
+ if pid_file.exists():
263
+ pid_file.unlink()
264
+
265
+ logger.info("Daemon stopped")
266
+
267
+ def _is_pidfile_running(self) -> bool:
268
+ """Check if daemon is running via PID file."""
269
+ pid = self._get_pidfile_pid()
270
+ if pid is None:
271
+ return False
272
+
273
+ try:
274
+ os.kill(pid, 0) # Check if process exists
275
+ return True
276
+ except ProcessLookupError:
277
+ # Process doesn't exist, clean up stale PID file
278
+ self.paths.pid_file.unlink(missing_ok=True)
279
+ return False
280
+
281
+ def _get_pidfile_pid(self) -> int | None:
282
+ """Get PID from PID file."""
283
+ pid_file = self.paths.pid_file
284
+ if not pid_file.exists():
285
+ return None
286
+
287
+ try:
288
+ return int(pid_file.read_text().strip())
289
+ except (ValueError, FileNotFoundError):
290
+ return None
291
+
292
+ def uninstall(self) -> None:
293
+ """Fully uninstall the daemon."""
294
+ self.stop()
295
+
296
+ # Remove launchd plist
297
+ if self._is_macos and self.paths.launchd_plist.exists():
298
+ self.paths.launchd_plist.unlink()
299
+
300
+ # Remove PID file
301
+ self.paths.pid_file.unlink(missing_ok=True)
302
+
303
+ logger.info("Daemon uninstalled")
@@ -0,0 +1,5 @@
1
+ """Installer module for Wingman."""
2
+
3
+ from .node_installer import NodeInstaller
4
+
5
+ __all__ = ["NodeInstaller"]