@agentunion/kite 1.0.7 → 1.2.0
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.
- package/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -147
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +230 -90
- package/package.json +1 -1
|
@@ -8,8 +8,10 @@ stdio lifecycle:
|
|
|
8
8
|
- Structured messages: JSON lines with "kite" field, dispatched via callback
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import glob
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
14
|
+
import re
|
|
13
15
|
import subprocess
|
|
14
16
|
import sys
|
|
15
17
|
import threading
|
|
@@ -17,6 +19,9 @@ import time
|
|
|
17
19
|
from dataclasses import dataclass, asdict
|
|
18
20
|
from typing import Callable
|
|
19
21
|
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
import ctypes
|
|
24
|
+
from ctypes import wintypes
|
|
20
25
|
|
|
21
26
|
IS_WINDOWS = sys.platform == "win32"
|
|
22
27
|
IS_MACOS = sys.platform == "darwin"
|
|
@@ -45,57 +50,311 @@ class ProcessManager:
|
|
|
45
50
|
self.instance_id = instance_id or str(os.getpid())
|
|
46
51
|
self._on_kite_message = on_kite_message
|
|
47
52
|
# Use KITE_MODULE_DATA (set by Launcher for itself)
|
|
48
|
-
|
|
49
|
-
self.
|
|
53
|
+
launcher_dir = os.environ.get("KITE_MODULE_DATA") or os.path.join(os.environ["KITE_INSTANCE_DIR"], "launcher")
|
|
54
|
+
self.data_dir = os.path.join(launcher_dir, "state")
|
|
55
|
+
os.makedirs(self.data_dir, exist_ok=True)
|
|
56
|
+
self._instance_num = self._allocate_instance_num()
|
|
57
|
+
suffix = "" if self._instance_num == 1 else f"~{self._instance_num}"
|
|
58
|
+
self._instance_suffix = suffix
|
|
59
|
+
self.records_path = os.path.join(self.data_dir, f"processes{suffix}.json")
|
|
50
60
|
self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
|
|
51
61
|
self._records: dict[str, ProcessRecord] = {} # name -> record
|
|
52
|
-
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def instance_suffix(self) -> str:
|
|
65
|
+
"""Return the instance suffix (empty string for instance 1, '~N' for N>1)."""
|
|
66
|
+
return self._instance_suffix
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def instance_num(self) -> int:
|
|
70
|
+
"""Return the instance number (1-based)."""
|
|
71
|
+
return self._instance_num
|
|
72
|
+
|
|
73
|
+
def get_alive_instances(self) -> list[dict]:
|
|
74
|
+
"""Scan processes*.json and return info about all alive instances (including self).
|
|
75
|
+
|
|
76
|
+
Returns list of dicts: {"num": int, "launcher_pid": int, "module_count": int, "is_self": bool, "debug": bool, "cwd": str}.
|
|
77
|
+
"""
|
|
78
|
+
instances = []
|
|
79
|
+
pattern = os.path.join(self.data_dir, "processes*.json")
|
|
80
|
+
for filepath in glob.glob(pattern):
|
|
81
|
+
basename = os.path.basename(filepath)
|
|
82
|
+
num = self._parse_instance_num(basename)
|
|
83
|
+
if num is None:
|
|
84
|
+
continue
|
|
85
|
+
pid = self._read_launcher_pid(filepath)
|
|
86
|
+
is_self = (pid == os.getpid())
|
|
87
|
+
if not is_self and (not pid or not self._is_pid_alive(pid)):
|
|
88
|
+
continue
|
|
89
|
+
# Read module count, debug flag, and cwd
|
|
90
|
+
module_count = 0
|
|
91
|
+
debug = False
|
|
92
|
+
cwd = ""
|
|
93
|
+
try:
|
|
94
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
95
|
+
data = json.load(f)
|
|
96
|
+
if isinstance(data, dict):
|
|
97
|
+
module_count = len(data.get("records", []))
|
|
98
|
+
debug = data.get("debug", False)
|
|
99
|
+
cwd = data.get("cwd", "")
|
|
100
|
+
elif isinstance(data, list):
|
|
101
|
+
module_count = len(data)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
instances.append({
|
|
105
|
+
"num": num,
|
|
106
|
+
"launcher_pid": pid or 0,
|
|
107
|
+
"module_count": module_count,
|
|
108
|
+
"is_self": is_self,
|
|
109
|
+
"debug": debug,
|
|
110
|
+
"cwd": cwd,
|
|
111
|
+
})
|
|
112
|
+
instances.sort(key=lambda x: x["num"])
|
|
113
|
+
return instances
|
|
114
|
+
|
|
115
|
+
def get_global_instances(self) -> list[dict]:
|
|
116
|
+
"""Scan all instance dirs under KITE_WORKSPACE for alive launcher instances.
|
|
117
|
+
|
|
118
|
+
Returns list of dicts:
|
|
119
|
+
{"instance_dir": str, "cwd": str, "num": int, "launcher_pid": int,
|
|
120
|
+
"module_count": int, "is_self": bool, "debug": bool}
|
|
121
|
+
"""
|
|
122
|
+
workspace = os.environ.get("KITE_WORKSPACE", "")
|
|
123
|
+
if not workspace or not os.path.isdir(workspace):
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
my_pid = os.getpid()
|
|
127
|
+
my_instance_dir = os.environ.get("KITE_INSTANCE_DIR", "")
|
|
128
|
+
results = []
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
entries = os.listdir(workspace)
|
|
132
|
+
except OSError:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
for entry in entries:
|
|
136
|
+
inst_path = os.path.join(workspace, entry)
|
|
137
|
+
if not os.path.isdir(inst_path):
|
|
138
|
+
continue
|
|
139
|
+
state_dir = os.path.join(inst_path, "launcher", "state")
|
|
140
|
+
if not os.path.isdir(state_dir):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Read .cwd for display (fallback if processes.json lacks cwd)
|
|
144
|
+
cwd_from_file = ""
|
|
145
|
+
cwd_file = os.path.join(inst_path, ".cwd")
|
|
146
|
+
try:
|
|
147
|
+
with open(cwd_file, "r", encoding="utf-8") as f:
|
|
148
|
+
cwd_from_file = f.read().strip()
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Scan processes*.json in this instance's state dir
|
|
153
|
+
pattern = os.path.join(state_dir, "processes*.json")
|
|
154
|
+
for filepath in glob.glob(pattern):
|
|
155
|
+
basename = os.path.basename(filepath)
|
|
156
|
+
num = self._parse_instance_num(basename)
|
|
157
|
+
if num is None:
|
|
158
|
+
continue
|
|
159
|
+
pid = self._read_launcher_pid(filepath)
|
|
160
|
+
is_self_dir = (os.path.normcase(os.path.normpath(inst_path))
|
|
161
|
+
== os.path.normcase(os.path.normpath(my_instance_dir)))
|
|
162
|
+
is_self = (pid == my_pid and is_self_dir)
|
|
163
|
+
if not is_self and (not pid or not self._is_pid_alive(pid)):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Read metadata
|
|
167
|
+
module_count = 0
|
|
168
|
+
debug = False
|
|
169
|
+
cwd = ""
|
|
170
|
+
try:
|
|
171
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
172
|
+
data = json.load(f)
|
|
173
|
+
if isinstance(data, dict):
|
|
174
|
+
module_count = len(data.get("records", []))
|
|
175
|
+
debug = data.get("debug", False)
|
|
176
|
+
cwd = data.get("cwd", "")
|
|
177
|
+
elif isinstance(data, list):
|
|
178
|
+
module_count = len(data)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
results.append({
|
|
183
|
+
"instance_dir": entry, # basename of instance dir
|
|
184
|
+
"cwd": cwd or cwd_from_file,
|
|
185
|
+
"num": num,
|
|
186
|
+
"launcher_pid": pid or 0,
|
|
187
|
+
"module_count": module_count,
|
|
188
|
+
"is_self": is_self,
|
|
189
|
+
"debug": debug,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
results.sort(key=lambda x: (x["instance_dir"], x["num"]))
|
|
193
|
+
return results
|
|
194
|
+
|
|
195
|
+
def _allocate_instance_num(self) -> int:
|
|
196
|
+
"""Scan processes*.json in data_dir and allocate the smallest available instance number.
|
|
197
|
+
|
|
198
|
+
An instance number is "available" if:
|
|
199
|
+
- No corresponding processes file exists, OR
|
|
200
|
+
- The file exists but its launcher_pid is dead (stale file from a crashed instance).
|
|
201
|
+
"""
|
|
202
|
+
# Collect occupied instance numbers (whose launcher is still alive)
|
|
203
|
+
occupied: set[int] = set()
|
|
204
|
+
pattern = os.path.join(self.data_dir, "processes*.json")
|
|
205
|
+
for filepath in glob.glob(pattern):
|
|
206
|
+
basename = os.path.basename(filepath)
|
|
207
|
+
num = self._parse_instance_num(basename)
|
|
208
|
+
if num is None:
|
|
209
|
+
continue
|
|
210
|
+
pid = self._read_launcher_pid(filepath)
|
|
211
|
+
if pid and pid != os.getpid() and self._is_pid_alive(pid):
|
|
212
|
+
occupied.add(num)
|
|
213
|
+
|
|
214
|
+
# Allocate smallest available number starting from 1
|
|
215
|
+
num = 1
|
|
216
|
+
while num in occupied:
|
|
217
|
+
num += 1
|
|
218
|
+
return num
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _parse_instance_num(basename: str) -> int | None:
|
|
222
|
+
"""Extract instance number from a processes filename.
|
|
223
|
+
|
|
224
|
+
'processes.json' -> 1, 'processes~3.json' -> 3, other -> None.
|
|
225
|
+
"""
|
|
226
|
+
if basename == "processes.json":
|
|
227
|
+
return 1
|
|
228
|
+
m = re.match(r"^processes~(\d+)\.json$", basename)
|
|
229
|
+
return int(m.group(1)) if m else None
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _read_launcher_pid(filepath: str) -> int | None:
|
|
233
|
+
"""Read launcher_pid from a processes JSON file. Returns None on error or old format."""
|
|
234
|
+
try:
|
|
235
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
236
|
+
data = json.load(f)
|
|
237
|
+
if isinstance(data, dict):
|
|
238
|
+
return data.get("launcher_pid")
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
return None
|
|
53
242
|
|
|
54
243
|
# ── Leftover cleanup ──
|
|
55
244
|
|
|
56
|
-
def cleanup_leftovers(self):
|
|
57
|
-
"""
|
|
58
|
-
|
|
59
|
-
|
|
245
|
+
def cleanup_leftovers(self) -> dict[str, int]:
|
|
246
|
+
"""Scan all processes*.json in data_dir (current instance dir only), clean up dead instances' child processes.
|
|
247
|
+
|
|
248
|
+
Returns dict: {instance_dir_basename: killed_count}.
|
|
249
|
+
"""
|
|
250
|
+
my_pid = os.getpid()
|
|
251
|
+
inst_name = os.path.basename(os.environ.get("KITE_INSTANCE_DIR", "")) or "unknown"
|
|
252
|
+
killed = 0
|
|
253
|
+
pattern = os.path.join(self.data_dir, "processes*.json")
|
|
254
|
+
for filepath in glob.glob(pattern):
|
|
255
|
+
killed += self._cleanup_one_leftover_file(filepath, my_pid)
|
|
256
|
+
return {inst_name: killed} if killed else {}
|
|
257
|
+
|
|
258
|
+
def cleanup_global_leftovers(self) -> dict[str, int]:
|
|
259
|
+
"""Scan all instance dirs under KITE_WORKSPACE for dead launcher files.
|
|
260
|
+
|
|
261
|
+
Same logic as cleanup_leftovers() but across all workspace instances.
|
|
262
|
+
Designed to run via run_in_executor (synchronous, blocking I/O).
|
|
263
|
+
Returns dict: {instance_dir_basename: killed_count}.
|
|
264
|
+
"""
|
|
265
|
+
workspace = os.environ.get("KITE_WORKSPACE", "")
|
|
266
|
+
if not workspace or not os.path.isdir(workspace):
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
my_pid = os.getpid()
|
|
270
|
+
result: dict[str, int] = {}
|
|
60
271
|
|
|
61
272
|
try:
|
|
62
|
-
|
|
273
|
+
entries = os.listdir(workspace)
|
|
274
|
+
except OSError:
|
|
275
|
+
return {}
|
|
276
|
+
|
|
277
|
+
for entry in entries:
|
|
278
|
+
state_dir = os.path.join(workspace, entry, "launcher", "state")
|
|
279
|
+
if not os.path.isdir(state_dir):
|
|
280
|
+
continue
|
|
281
|
+
pattern = os.path.join(state_dir, "processes*.json")
|
|
282
|
+
for filepath in glob.glob(pattern):
|
|
283
|
+
killed = self._cleanup_one_leftover_file(filepath, my_pid)
|
|
284
|
+
if killed:
|
|
285
|
+
result[entry] = result.get(entry, 0) + killed
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
def _cleanup_one_leftover_file(self, filepath: str, my_pid: int) -> int:
|
|
289
|
+
"""Process a single processes*.json file for leftover cleanup.
|
|
290
|
+
|
|
291
|
+
- Skip if launcher_pid matches my_pid (our own records).
|
|
292
|
+
- Skip if launcher_pid is still alive (parallel instance).
|
|
293
|
+
- Otherwise clean up child processes and delete the file.
|
|
294
|
+
Returns number of processes killed.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
63
298
|
saved = json.load(f)
|
|
64
299
|
except Exception:
|
|
65
300
|
try:
|
|
66
|
-
os.remove(
|
|
301
|
+
os.remove(filepath)
|
|
67
302
|
except OSError:
|
|
68
303
|
pass
|
|
69
|
-
return
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
# New format: {"launcher_pid": N, "records": [...]}
|
|
307
|
+
# Old format: plain array [...]
|
|
308
|
+
if isinstance(saved, dict):
|
|
309
|
+
launcher_pid = saved.get("launcher_pid")
|
|
310
|
+
records = saved.get("records", [])
|
|
311
|
+
elif isinstance(saved, list):
|
|
312
|
+
launcher_pid = None
|
|
313
|
+
records = saved
|
|
314
|
+
else:
|
|
315
|
+
try:
|
|
316
|
+
os.remove(filepath)
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
# Skip our own records (written by current process)
|
|
322
|
+
if launcher_pid == my_pid:
|
|
323
|
+
return 0
|
|
70
324
|
|
|
71
|
-
|
|
325
|
+
# If the owning launcher is still alive, skip this file (parallel instance)
|
|
326
|
+
if launcher_pid and self._is_pid_alive(launcher_pid):
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
# Dead launcher (or old format) — clean up its child processes
|
|
330
|
+
killed = 0
|
|
331
|
+
for entry in records:
|
|
72
332
|
pid = entry.get("pid", 0)
|
|
73
333
|
cmd = entry.get("cmd", [])
|
|
74
334
|
name = entry.get("name", "?")
|
|
75
335
|
if not pid:
|
|
76
336
|
continue
|
|
77
337
|
if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
|
|
78
|
-
print(f"[launcher] 正在清理残留进程: {name} (PID {pid})")
|
|
338
|
+
print(f"[launcher] 正在清理残留进程: {name} (PID {pid}) [{os.path.basename(filepath)}]")
|
|
79
339
|
self._force_kill(pid)
|
|
340
|
+
killed += 1
|
|
80
341
|
else:
|
|
81
342
|
print(f"[launcher] 残留进程 {name} (PID {pid}) 已不存在")
|
|
82
343
|
|
|
83
344
|
try:
|
|
84
|
-
os.remove(
|
|
345
|
+
os.remove(filepath)
|
|
85
346
|
except OSError:
|
|
86
347
|
pass
|
|
348
|
+
return killed
|
|
87
349
|
|
|
88
350
|
def _is_pid_alive(self, pid: int) -> bool:
|
|
89
|
-
"""Check if a process with given PID exists.
|
|
351
|
+
"""Check if a process with given PID exists.
|
|
352
|
+
|
|
353
|
+
Windows: Uses OpenProcess API via ctypes (fast, ~0.1ms per check).
|
|
354
|
+
Linux/macOS: Uses os.kill(pid, 0) signal check.
|
|
355
|
+
"""
|
|
90
356
|
if IS_WINDOWS:
|
|
91
|
-
|
|
92
|
-
result = subprocess.run(
|
|
93
|
-
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
94
|
-
capture_output=True, text=True, timeout=5,
|
|
95
|
-
)
|
|
96
|
-
return str(pid) in result.stdout
|
|
97
|
-
except Exception:
|
|
98
|
-
return False
|
|
357
|
+
return self._win_is_pid_alive_ctypes(pid)
|
|
99
358
|
else:
|
|
100
359
|
try:
|
|
101
360
|
os.kill(pid, 0)
|
|
@@ -103,6 +362,95 @@ class ProcessManager:
|
|
|
103
362
|
except OSError:
|
|
104
363
|
return False
|
|
105
364
|
|
|
365
|
+
def _win_get_all_alive_pids(self) -> set:
|
|
366
|
+
"""Windows: Get all alive PIDs in one batch call (fallback optimization).
|
|
367
|
+
|
|
368
|
+
Returns set of PIDs. Returns empty set on failure.
|
|
369
|
+
"""
|
|
370
|
+
if not IS_WINDOWS:
|
|
371
|
+
return set()
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
result = subprocess.run(
|
|
375
|
+
["tasklist", "/FO", "CSV", "/NH"],
|
|
376
|
+
capture_output=True, timeout=10,
|
|
377
|
+
)
|
|
378
|
+
# Use errors='replace' to handle any encoding (GBK, UTF-8, etc.)
|
|
379
|
+
stdout = result.stdout.decode(errors='replace')
|
|
380
|
+
pids = set()
|
|
381
|
+
for line in stdout.strip().split('\n'):
|
|
382
|
+
if not line:
|
|
383
|
+
continue
|
|
384
|
+
# CSV format: "image_name","pid","session_name","session#","mem_usage"
|
|
385
|
+
parts = line.split(',')
|
|
386
|
+
if len(parts) >= 2:
|
|
387
|
+
try:
|
|
388
|
+
pid_str = parts[1].strip('" \r')
|
|
389
|
+
pids.add(int(pid_str))
|
|
390
|
+
except (ValueError, IndexError):
|
|
391
|
+
continue
|
|
392
|
+
return pids
|
|
393
|
+
except Exception:
|
|
394
|
+
return set()
|
|
395
|
+
|
|
396
|
+
def _win_is_pid_alive_ctypes(self, pid: int) -> bool:
|
|
397
|
+
"""Windows: Check if PID exists and is still running using OpenProcess API.
|
|
398
|
+
|
|
399
|
+
Returns True only if process exists AND has not exited.
|
|
400
|
+
Handles zombie processes (exited but handle still open) correctly
|
|
401
|
+
by checking GetExitCodeProcess after OpenProcess succeeds.
|
|
402
|
+
|
|
403
|
+
Falls back to batch tasklist on ctypes failure (rare).
|
|
404
|
+
"""
|
|
405
|
+
if not IS_WINDOWS:
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
# Constants from Windows API
|
|
409
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
410
|
+
ERROR_INVALID_PARAMETER = 87
|
|
411
|
+
ERROR_ACCESS_DENIED = 5
|
|
412
|
+
STILL_ACTIVE = 259
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
# Try to open the process with minimal permissions
|
|
416
|
+
handle = ctypes.windll.kernel32.OpenProcess(
|
|
417
|
+
PROCESS_QUERY_LIMITED_INFORMATION,
|
|
418
|
+
False, # bInheritHandle
|
|
419
|
+
pid
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if handle:
|
|
423
|
+
try:
|
|
424
|
+
# OpenProcess succeeds even for zombie processes (exited but handle
|
|
425
|
+
# not yet closed by parent). Check actual exit status.
|
|
426
|
+
exit_code = wintypes.DWORD()
|
|
427
|
+
if ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
428
|
+
return exit_code.value == STILL_ACTIVE
|
|
429
|
+
# GetExitCodeProcess failed — assume alive to be safe
|
|
430
|
+
return True
|
|
431
|
+
finally:
|
|
432
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
433
|
+
|
|
434
|
+
# OpenProcess failed, check why
|
|
435
|
+
error_code = ctypes.windll.kernel32.GetLastError()
|
|
436
|
+
|
|
437
|
+
if error_code == ERROR_ACCESS_DENIED:
|
|
438
|
+
# Process exists but we don't have permission (e.g., SYSTEM process)
|
|
439
|
+
# For our use case, this means it's NOT our child process
|
|
440
|
+
return False
|
|
441
|
+
elif error_code == ERROR_INVALID_PARAMETER:
|
|
442
|
+
# Invalid PID (process doesn't exist)
|
|
443
|
+
return False
|
|
444
|
+
else:
|
|
445
|
+
# Other errors (e.g., process exited between check and open)
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
except Exception:
|
|
449
|
+
# Fallback: batch tasklist (fetch once, cache for subsequent calls)
|
|
450
|
+
if not hasattr(self, '_alive_pids_cache'):
|
|
451
|
+
self._alive_pids_cache = self._win_get_all_alive_pids()
|
|
452
|
+
return pid in self._alive_pids_cache
|
|
453
|
+
|
|
106
454
|
def _cmd_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
107
455
|
"""Verify the process command line matches what we expect (prevent killing recycled PIDs).
|
|
108
456
|
Uses multi-strategy fallback per platform for robustness.
|
|
@@ -150,9 +498,9 @@ class ProcessManager:
|
|
|
150
498
|
r = subprocess.run(
|
|
151
499
|
["powershell", "-NoProfile", "-Command",
|
|
152
500
|
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"],
|
|
153
|
-
capture_output=True,
|
|
501
|
+
capture_output=True, timeout=8,
|
|
154
502
|
)
|
|
155
|
-
return r.stdout.strip()
|
|
503
|
+
return r.stdout.decode(errors='replace').strip()
|
|
156
504
|
except Exception:
|
|
157
505
|
return ""
|
|
158
506
|
|
|
@@ -162,22 +510,31 @@ class ProcessManager:
|
|
|
162
510
|
r = subprocess.run(
|
|
163
511
|
["wmic", "process", "where", f"ProcessId={pid}",
|
|
164
512
|
"get", "CommandLine", "/value"],
|
|
165
|
-
capture_output=True,
|
|
513
|
+
capture_output=True, timeout=5,
|
|
166
514
|
)
|
|
167
|
-
return r.stdout.strip()
|
|
515
|
+
return r.stdout.decode(errors='replace').strip()
|
|
168
516
|
except Exception:
|
|
169
517
|
return ""
|
|
170
518
|
|
|
171
519
|
def _exe_name_matches(self, pid: int, expected_cmd: list) -> bool:
|
|
172
|
-
"""Last resort: check if process image name matches expected executable.
|
|
520
|
+
"""Last resort: check if process image name matches expected executable.
|
|
521
|
+
|
|
522
|
+
Windows: Uses OpenProcess + QueryFullProcessImageName for fast check.
|
|
523
|
+
Falls back to tasklist if ctypes fails.
|
|
524
|
+
"""
|
|
173
525
|
exe = os.path.basename(expected_cmd[0]).lower()
|
|
174
526
|
if exe.endswith(".exe"):
|
|
175
527
|
exe = exe[:-4]
|
|
176
528
|
if IS_WINDOWS:
|
|
529
|
+
# Try fast ctypes method first
|
|
530
|
+
image_name = self._win_get_image_name_ctypes(pid)
|
|
531
|
+
if image_name:
|
|
532
|
+
return exe in image_name.lower()
|
|
533
|
+
# Fallback to tasklist
|
|
177
534
|
try:
|
|
178
535
|
r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
179
|
-
capture_output=True,
|
|
180
|
-
return exe in r.stdout.lower()
|
|
536
|
+
capture_output=True, timeout=5)
|
|
537
|
+
return exe in r.stdout.decode(errors='replace').lower()
|
|
181
538
|
except Exception:
|
|
182
539
|
return False
|
|
183
540
|
else:
|
|
@@ -188,6 +545,50 @@ class ProcessManager:
|
|
|
188
545
|
except Exception:
|
|
189
546
|
return False
|
|
190
547
|
|
|
548
|
+
def _win_get_image_name_ctypes(self, pid: int) -> str:
|
|
549
|
+
"""Windows: Get process image name using OpenProcess + QueryFullProcessImageName.
|
|
550
|
+
|
|
551
|
+
Returns empty string on failure.
|
|
552
|
+
"""
|
|
553
|
+
if not IS_WINDOWS:
|
|
554
|
+
return ""
|
|
555
|
+
|
|
556
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
handle = ctypes.windll.kernel32.OpenProcess(
|
|
560
|
+
PROCESS_QUERY_LIMITED_INFORMATION,
|
|
561
|
+
False,
|
|
562
|
+
pid
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if not handle:
|
|
566
|
+
return ""
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
# Buffer for image name (MAX_PATH = 260)
|
|
570
|
+
buffer_size = wintypes.DWORD(260)
|
|
571
|
+
buffer = ctypes.create_unicode_buffer(buffer_size.value)
|
|
572
|
+
|
|
573
|
+
# QueryFullProcessImageNameW
|
|
574
|
+
success = ctypes.windll.kernel32.QueryFullProcessImageNameW(
|
|
575
|
+
handle,
|
|
576
|
+
0, # dwFlags (0 = Win32 path format)
|
|
577
|
+
buffer,
|
|
578
|
+
ctypes.byref(buffer_size)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if success:
|
|
582
|
+
return os.path.basename(buffer.value)
|
|
583
|
+
else:
|
|
584
|
+
return ""
|
|
585
|
+
|
|
586
|
+
finally:
|
|
587
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
588
|
+
|
|
589
|
+
except Exception:
|
|
590
|
+
return ""
|
|
591
|
+
|
|
191
592
|
def _force_kill(self, pid: int):
|
|
192
593
|
"""Force kill a process by PID."""
|
|
193
594
|
try:
|
|
@@ -296,8 +697,10 @@ class ProcessManager:
|
|
|
296
697
|
return False
|
|
297
698
|
|
|
298
699
|
def close_stdio(self, name: str):
|
|
299
|
-
"""Close stdin
|
|
300
|
-
|
|
700
|
+
"""Close stdin for a module. Called by Launcher after module.ready → module.started.
|
|
701
|
+
Only closes stdin (no more messages to send). stdout is left open so the reader
|
|
702
|
+
thread can continue relaying logs — it exits naturally when the process dies.
|
|
703
|
+
On Windows, closing stdout while a reader thread is blocked on readline() can deadlock.
|
|
301
704
|
"""
|
|
302
705
|
proc = self._processes.get(name)
|
|
303
706
|
if not proc:
|
|
@@ -307,11 +710,6 @@ class ProcessManager:
|
|
|
307
710
|
proc.stdin.close()
|
|
308
711
|
except Exception:
|
|
309
712
|
pass
|
|
310
|
-
if proc.stdout and not proc.stdout.closed:
|
|
311
|
-
try:
|
|
312
|
-
proc.stdout.close()
|
|
313
|
-
except Exception:
|
|
314
|
-
pass
|
|
315
713
|
|
|
316
714
|
def _build_cmd(self, info) -> list:
|
|
317
715
|
"""Build the command list based on module runtime.
|
|
@@ -398,11 +796,18 @@ class ProcessManager:
|
|
|
398
796
|
if not self._processes:
|
|
399
797
|
return
|
|
400
798
|
|
|
401
|
-
|
|
402
|
-
|
|
799
|
+
# Filter to only still-running processes
|
|
800
|
+
alive = [n for n, p in self._processes.items() if p.poll() is None]
|
|
801
|
+
if not alive:
|
|
802
|
+
# All already exited — just clean up bookkeeping
|
|
803
|
+
self._processes.clear()
|
|
804
|
+
self._records.clear()
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
print(f"[launcher] 正在停止所有模块: {', '.join(alive)}")
|
|
403
808
|
|
|
404
809
|
# Phase 1: close stdio and send terminate to all
|
|
405
|
-
for name in
|
|
810
|
+
for name in alive:
|
|
406
811
|
self.close_stdio(name)
|
|
407
812
|
proc = self._processes.get(name)
|
|
408
813
|
if proc and proc.poll() is None:
|
|
@@ -410,7 +815,7 @@ class ProcessManager:
|
|
|
410
815
|
|
|
411
816
|
# Phase 2: wait for all to exit
|
|
412
817
|
deadline = time.time() + timeout
|
|
413
|
-
for name in
|
|
818
|
+
for name in alive:
|
|
414
819
|
proc = self._processes.get(name)
|
|
415
820
|
if not proc:
|
|
416
821
|
continue
|
|
@@ -421,7 +826,7 @@ class ProcessManager:
|
|
|
421
826
|
pass
|
|
422
827
|
|
|
423
828
|
# Phase 3: force kill survivors
|
|
424
|
-
for name in
|
|
829
|
+
for name in alive:
|
|
425
830
|
proc = self._processes.get(name)
|
|
426
831
|
if proc and proc.poll() is None:
|
|
427
832
|
print(f"[launcher] 强制终止 {name} (PID {proc.pid})")
|
|
@@ -438,8 +843,13 @@ class ProcessManager:
|
|
|
438
843
|
# ── Persistence & monitoring ──
|
|
439
844
|
|
|
440
845
|
def persist_records(self):
|
|
441
|
-
"""Write current process records to processes.json."""
|
|
442
|
-
data =
|
|
846
|
+
"""Write current process records to processes.json (with launcher_pid for multi-instance)."""
|
|
847
|
+
data = {
|
|
848
|
+
"launcher_pid": os.getpid(),
|
|
849
|
+
"debug": os.environ.get("KITE_DEBUG") == "1",
|
|
850
|
+
"cwd": os.environ.get("KITE_CWD", ""),
|
|
851
|
+
"records": [asdict(r) for r in self._records.values()],
|
|
852
|
+
}
|
|
443
853
|
self._write_records_file(data)
|
|
444
854
|
|
|
445
855
|
def check_exited(self) -> list[tuple[str, int]]:
|
|
@@ -461,10 +871,10 @@ class ProcessManager:
|
|
|
461
871
|
proc = self._processes.get(name)
|
|
462
872
|
return proc is not None and proc.poll() is None
|
|
463
873
|
|
|
464
|
-
def _write_records_file(self, data
|
|
465
|
-
"""Write data
|
|
874
|
+
def _write_records_file(self, data):
|
|
875
|
+
"""Write data to the instance's processes JSON file."""
|
|
466
876
|
try:
|
|
467
877
|
with open(self.records_path, "w", encoding="utf-8") as f:
|
|
468
878
|
json.dump(data, f, indent=2)
|
|
469
879
|
except Exception as e:
|
|
470
|
-
print(f"[launcher] 警告: 写入
|
|
880
|
+
print(f"[launcher] 警告: 写入 {os.path.basename(self.records_path)} 失败: {e}")
|