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.
- share/wingman/node_listener/package-lock.json +1785 -0
- share/wingman/node_listener/package.json +50 -0
- share/wingman/node_listener/src/index.ts +108 -0
- share/wingman/node_listener/src/ipc.ts +70 -0
- share/wingman/node_listener/src/messageHandler.ts +135 -0
- share/wingman/node_listener/src/socket.ts +244 -0
- share/wingman/node_listener/src/types.d.ts +13 -0
- share/wingman/node_listener/tsconfig.json +19 -0
- wingman/__init__.py +4 -0
- wingman/__main__.py +6 -0
- wingman/cli/__init__.py +5 -0
- wingman/cli/commands/__init__.py +1 -0
- wingman/cli/commands/auth.py +90 -0
- wingman/cli/commands/config.py +109 -0
- wingman/cli/commands/init.py +71 -0
- wingman/cli/commands/logs.py +84 -0
- wingman/cli/commands/start.py +111 -0
- wingman/cli/commands/status.py +84 -0
- wingman/cli/commands/stop.py +33 -0
- wingman/cli/commands/uninstall.py +113 -0
- wingman/cli/main.py +50 -0
- wingman/cli/wizard.py +356 -0
- wingman/config/__init__.py +31 -0
- wingman/config/paths.py +153 -0
- wingman/config/personality.py +155 -0
- wingman/config/registry.py +343 -0
- wingman/config/settings.py +294 -0
- wingman/core/__init__.py +16 -0
- wingman/core/agent.py +257 -0
- wingman/core/ipc_handler.py +124 -0
- wingman/core/llm/__init__.py +5 -0
- wingman/core/llm/client.py +77 -0
- wingman/core/memory/__init__.py +6 -0
- wingman/core/memory/context.py +109 -0
- wingman/core/memory/models.py +213 -0
- wingman/core/message_processor.py +277 -0
- wingman/core/policy/__init__.py +5 -0
- wingman/core/policy/evaluator.py +265 -0
- wingman/core/process_manager.py +135 -0
- wingman/core/safety/__init__.py +8 -0
- wingman/core/safety/cooldown.py +63 -0
- wingman/core/safety/quiet_hours.py +75 -0
- wingman/core/safety/rate_limiter.py +58 -0
- wingman/core/safety/triggers.py +117 -0
- wingman/core/transports/__init__.py +14 -0
- wingman/core/transports/base.py +106 -0
- wingman/core/transports/imessage/__init__.py +5 -0
- wingman/core/transports/imessage/db_listener.py +280 -0
- wingman/core/transports/imessage/sender.py +162 -0
- wingman/core/transports/imessage/transport.py +140 -0
- wingman/core/transports/whatsapp.py +180 -0
- wingman/daemon/__init__.py +5 -0
- wingman/daemon/manager.py +303 -0
- wingman/installer/__init__.py +5 -0
- wingman/installer/node_installer.py +253 -0
- wingman_ai-1.0.0.dist-info/METADATA +553 -0
- wingman_ai-1.0.0.dist-info/RECORD +60 -0
- wingman_ai-1.0.0.dist-info/WHEEL +4 -0
- wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
- 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,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")
|