@agentunion/kite 1.0.7 → 1.3.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/__init__.py +1 -0
  5. package/extensions/agents/assistant/__init__.py +1 -0
  6. package/extensions/agents/assistant/entry.py +329 -0
  7. package/extensions/agents/assistant/module.md +22 -0
  8. package/extensions/agents/assistant/server.py +197 -0
  9. package/extensions/channels/__init__.py +1 -0
  10. package/extensions/channels/acp_channel/__init__.py +1 -0
  11. package/extensions/channels/acp_channel/entry.py +329 -0
  12. package/extensions/channels/acp_channel/module.md +22 -0
  13. package/extensions/channels/acp_channel/server.py +197 -0
  14. package/extensions/event_hub_bench/entry.py +624 -379
  15. package/extensions/event_hub_bench/module.md +2 -1
  16. package/extensions/services/backup/__init__.py +1 -0
  17. package/extensions/services/backup/entry.py +508 -0
  18. package/extensions/services/backup/module.md +22 -0
  19. package/extensions/services/model_service/__init__.py +1 -0
  20. package/extensions/services/model_service/entry.py +508 -0
  21. package/extensions/services/model_service/module.md +22 -0
  22. package/extensions/services/watchdog/entry.py +468 -102
  23. package/extensions/services/watchdog/module.md +3 -0
  24. package/extensions/services/watchdog/monitor.py +170 -69
  25. package/extensions/services/web/__init__.py +1 -0
  26. package/extensions/services/web/config.yaml +149 -0
  27. package/extensions/services/web/entry.py +390 -0
  28. package/extensions/services/web/module.md +24 -0
  29. package/extensions/services/web/routes/__init__.py +1 -0
  30. package/extensions/services/web/routes/routes_call.py +189 -0
  31. package/extensions/services/web/routes/routes_config.py +512 -0
  32. package/extensions/services/web/routes/routes_contacts.py +98 -0
  33. package/extensions/services/web/routes/routes_devlog.py +99 -0
  34. package/extensions/services/web/routes/routes_phone.py +81 -0
  35. package/extensions/services/web/routes/routes_sms.py +48 -0
  36. package/extensions/services/web/routes/routes_stats.py +17 -0
  37. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  38. package/extensions/services/web/routes/schemas.py +216 -0
  39. package/extensions/services/web/server.py +375 -0
  40. package/extensions/services/web/static/css/style.css +1064 -0
  41. package/extensions/services/web/static/index.html +1445 -0
  42. package/extensions/services/web/static/js/app.js +4671 -0
  43. package/extensions/services/web/vendor/__init__.py +1 -0
  44. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  45. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  46. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  47. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  48. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  49. package/extensions/services/web/vendor/config.py +139 -0
  50. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  51. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  52. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  53. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  54. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  55. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  56. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  57. package/extensions/services/web/vendor/storage/identity.py +312 -0
  58. package/extensions/services/web/vendor/storage/store.py +507 -0
  59. package/extensions/services/web/vendor/task/manager.py +864 -0
  60. package/extensions/services/web/vendor/task/models.py +45 -0
  61. package/extensions/services/web/vendor/task/webhook.py +263 -0
  62. package/extensions/services/web/vendor/tools/registry.py +321 -0
  63. package/kernel/__init__.py +0 -0
  64. package/kernel/entry.py +407 -0
  65. package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
  66. package/kernel/module.md +33 -0
  67. package/{core/registry/store.py → kernel/registry_store.py} +23 -8
  68. package/kernel/rpc_router.py +388 -0
  69. package/kernel/server.py +267 -0
  70. package/launcher/__init__.py +10 -0
  71. package/launcher/__main__.py +6 -0
  72. package/launcher/count_lines.py +258 -0
  73. package/launcher/entry.py +1778 -0
  74. package/launcher/logging_setup.py +289 -0
  75. package/{core/launcher → launcher}/module_scanner.py +11 -6
  76. package/launcher/process_manager.py +880 -0
  77. package/main.py +11 -210
  78. package/package.json +6 -9
  79. package/__init__.py +0 -1
  80. package/__main__.py +0 -15
  81. package/core/event_hub/BENCHMARK.md +0 -94
  82. package/core/event_hub/bench.py +0 -459
  83. package/core/event_hub/bench_extreme.py +0 -308
  84. package/core/event_hub/bench_perf.py +0 -350
  85. package/core/event_hub/entry.py +0 -157
  86. package/core/event_hub/module.md +0 -20
  87. package/core/event_hub/server.py +0 -206
  88. package/core/launcher/entry.py +0 -1158
  89. package/core/launcher/process_manager.py +0 -470
  90. package/core/registry/entry.py +0 -110
  91. package/core/registry/module.md +0 -30
  92. package/core/registry/server.py +0 -289
  93. package/extensions/services/watchdog/server.py +0 -167
  94. /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
  95. /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
  96. /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
  97. /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
  98. /package/{core/event_hub → kernel}/dedup.py +0 -0
  99. /package/{core/event_hub → kernel}/router.py +0 -0
  100. /package/{core/launcher → launcher}/module.md +0 -0
@@ -0,0 +1,880 @@
1
+ """
2
+ Manage child process lifecycle: start, stop, monitor, cleanup leftovers.
3
+ Cross-platform support for Windows, Linux, and macOS.
4
+
5
+ stdio lifecycle:
6
+ - stdin: write boot_info → keep open (Launcher may send more messages) → close after module.ready
7
+ - stdout: read structured messages + log lines → close after module.ready
8
+ - Structured messages: JSON lines with "kite" field, dispatched via callback
9
+ """
10
+
11
+ import glob
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ from dataclasses import dataclass, asdict
20
+ from typing import Callable
21
+
22
+ if sys.platform == "win32":
23
+ import ctypes
24
+ from ctypes import wintypes
25
+
26
+ IS_WINDOWS = sys.platform == "win32"
27
+ IS_MACOS = sys.platform == "darwin"
28
+
29
+
30
+ @dataclass
31
+ class ProcessRecord:
32
+ name: str
33
+ pid: int
34
+ cmd: list
35
+ module_dir: str
36
+ started_at: float
37
+
38
+
39
+ class ProcessManager:
40
+ """Manage child processes for all Kite modules."""
41
+
42
+ def __init__(self, kite_token: str, instance_id: str = "",
43
+ on_kite_message: Callable[[str, dict], None] | None = None):
44
+ """
45
+ Args:
46
+ on_kite_message: callback(module_name, msg_dict) for structured stdout messages.
47
+ Called from stdout reader thread — must be thread-safe.
48
+ """
49
+ self.kite_token = kite_token
50
+ self.instance_id = instance_id or str(os.getpid())
51
+ self._on_kite_message = on_kite_message
52
+ # Use KITE_MODULE_DATA (set by Launcher for itself)
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")
60
+ self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
61
+ self._records: dict[str, ProcessRecord] = {} # name -> record
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
242
+
243
+ # ── Leftover cleanup ──
244
+
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] = {}
271
+
272
+ try:
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:
298
+ saved = json.load(f)
299
+ except Exception:
300
+ try:
301
+ os.remove(filepath)
302
+ except OSError:
303
+ pass
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
324
+
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:
332
+ pid = entry.get("pid", 0)
333
+ cmd = entry.get("cmd", [])
334
+ name = entry.get("name", "?")
335
+ if not pid:
336
+ continue
337
+ if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
338
+ print(f"[launcher] 正在清理残留进程: {name} (PID {pid}) [{os.path.basename(filepath)}]")
339
+ self._force_kill(pid)
340
+ killed += 1
341
+ else:
342
+ print(f"[launcher] 残留进程 {name} (PID {pid}) 已不存在")
343
+
344
+ try:
345
+ os.remove(filepath)
346
+ except OSError:
347
+ pass
348
+ return killed
349
+
350
+ def _is_pid_alive(self, pid: int) -> bool:
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
+ """
356
+ if IS_WINDOWS:
357
+ return self._win_is_pid_alive_ctypes(pid)
358
+ else:
359
+ try:
360
+ os.kill(pid, 0)
361
+ return True
362
+ except OSError:
363
+ return False
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
+
454
+ def _cmd_matches(self, pid: int, expected_cmd: list) -> bool:
455
+ """Verify the process command line matches what we expect (prevent killing recycled PIDs).
456
+ Uses multi-strategy fallback per platform for robustness.
457
+ """
458
+ if not expected_cmd:
459
+ return False
460
+ cmdline = self._get_process_cmdline(pid)
461
+ if cmdline:
462
+ return expected_cmd[-1] in cmdline
463
+ # Degraded fallback: match executable image name only
464
+ return self._exe_name_matches(pid, expected_cmd)
465
+
466
+ def _get_process_cmdline(self, pid: int) -> str:
467
+ """Get full command line of a process. Returns empty string on failure."""
468
+ if IS_WINDOWS:
469
+ for method in (self._win_powershell_cmdline, self._win_wmic_cmdline):
470
+ result = method(pid)
471
+ if result:
472
+ return result
473
+ return ""
474
+ elif IS_MACOS:
475
+ try:
476
+ r = subprocess.run(["ps", "-p", str(pid), "-o", "command="],
477
+ capture_output=True, text=True, timeout=5)
478
+ return r.stdout.strip()
479
+ except Exception:
480
+ return ""
481
+ else:
482
+ # Linux: /proc first, ps fallback
483
+ try:
484
+ with open(f"/proc/{pid}/cmdline", "r") as f:
485
+ return f.read()
486
+ except Exception:
487
+ pass
488
+ try:
489
+ r = subprocess.run(["ps", "-p", str(pid), "-o", "args="],
490
+ capture_output=True, text=True, timeout=5)
491
+ return r.stdout.strip()
492
+ except Exception:
493
+ return ""
494
+
495
+ def _win_powershell_cmdline(self, pid: int) -> str:
496
+ """Windows: Get cmdline via PowerShell Get-CimInstance (modern, replaces wmic)."""
497
+ try:
498
+ r = subprocess.run(
499
+ ["powershell", "-NoProfile", "-Command",
500
+ f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"],
501
+ capture_output=True, timeout=8,
502
+ )
503
+ return r.stdout.decode(errors='replace').strip()
504
+ except Exception:
505
+ return ""
506
+
507
+ def _win_wmic_cmdline(self, pid: int) -> str:
508
+ """Windows: Get cmdline via wmic (legacy fallback, deprecated but widely available)."""
509
+ try:
510
+ r = subprocess.run(
511
+ ["wmic", "process", "where", f"ProcessId={pid}",
512
+ "get", "CommandLine", "/value"],
513
+ capture_output=True, timeout=5,
514
+ )
515
+ return r.stdout.decode(errors='replace').strip()
516
+ except Exception:
517
+ return ""
518
+
519
+ def _exe_name_matches(self, pid: int, expected_cmd: list) -> bool:
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
+ """
525
+ exe = os.path.basename(expected_cmd[0]).lower()
526
+ if exe.endswith(".exe"):
527
+ exe = exe[:-4]
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
534
+ try:
535
+ r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
536
+ capture_output=True, timeout=5)
537
+ return exe in r.stdout.decode(errors='replace').lower()
538
+ except Exception:
539
+ return False
540
+ else:
541
+ try:
542
+ r = subprocess.run(["ps", "-p", str(pid), "-o", "comm="],
543
+ capture_output=True, text=True, timeout=5)
544
+ return exe in r.stdout.lower()
545
+ except Exception:
546
+ return False
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
+
592
+ def _force_kill(self, pid: int):
593
+ """Force kill a process by PID."""
594
+ try:
595
+ if IS_WINDOWS:
596
+ subprocess.run(["taskkill", "/F", "/PID", str(pid)],
597
+ capture_output=True, timeout=5)
598
+ else:
599
+ import signal
600
+ os.kill(pid, signal.SIGKILL)
601
+ except Exception as e:
602
+ print(f"[launcher] 警告: 强制终止 PID {pid} 失败: {e}")
603
+
604
+ # ── Start module ──
605
+
606
+ def start_module(self, info, boot_info: dict = None) -> bool:
607
+ """Start a module as a subprocess. Returns True on success.
608
+ If boot_info is provided, it is written as JSON to the child's stdin pipe.
609
+ stdin is kept open after boot_info for Launcher to send additional messages.
610
+ """
611
+ from .module_scanner import ModuleInfo
612
+ info: ModuleInfo
613
+
614
+ if info.name in self._processes:
615
+ proc = self._processes[info.name]
616
+ if proc.poll() is None:
617
+ print(f"[launcher] {info.name} 已在运行 (PID {proc.pid})")
618
+ return True
619
+
620
+ cmd = self._build_cmd(info)
621
+ if not cmd:
622
+ print(f"[launcher] 错误: 无法构建 {info.name} 的启动命令")
623
+ return False
624
+
625
+ env = os.environ.copy()
626
+ env["PYTHONUTF8"] = "1" # Force UTF-8 in child Python processes
627
+ env["PYTHONUNBUFFERED"] = "1" # Disable output buffering for real-time logs
628
+ env["KITE_MODULE_DATA"] = os.path.join(os.environ["KITE_INSTANCE_DIR"], info.name)
629
+ if info.launch.env:
630
+ env.update(info.launch.env)
631
+
632
+ creation_flags = 0
633
+ if IS_WINDOWS:
634
+ creation_flags = subprocess.CREATE_NO_WINDOW
635
+
636
+ use_stdin = boot_info is not None and info.launch.boot_stdin
637
+
638
+ cwd = info.module_dir
639
+ if info.launch.cwd:
640
+ cwd = os.path.normpath(os.path.join(info.module_dir, info.launch.cwd))
641
+
642
+ try:
643
+ proc = subprocess.Popen(
644
+ cmd,
645
+ cwd=cwd,
646
+ env=env,
647
+ stdin=subprocess.PIPE if use_stdin else None,
648
+ stdout=subprocess.PIPE,
649
+ stderr=subprocess.STDOUT,
650
+ creationflags=creation_flags,
651
+ )
652
+ except Exception as e:
653
+ print(f"[launcher] 错误: 启动 {info.name} 失败: {e}")
654
+ return False
655
+
656
+ # Write boot_info to stdin — keep stdin open for additional messages
657
+ if use_stdin and proc.stdin:
658
+ try:
659
+ proc.stdin.write(json.dumps(boot_info).encode() + b"\n")
660
+ proc.stdin.flush()
661
+ except Exception as e:
662
+ print(f"[launcher] 警告: 向 {info.name} 写入 boot_info 失败: {e}")
663
+
664
+ self._processes[info.name] = proc
665
+ self._records[info.name] = ProcessRecord(
666
+ name=info.name,
667
+ pid=proc.pid,
668
+ cmd=cmd,
669
+ module_dir=info.module_dir,
670
+ started_at=time.time(),
671
+ )
672
+
673
+ # Daemon thread to read stdout: structured messages + log lines
674
+ t = threading.Thread(
675
+ target=self._read_stdout, args=(info.name, proc),
676
+ daemon=True,
677
+ )
678
+ t.start()
679
+
680
+ print(f"[launcher] 已启动 {info.name} (PID {proc.pid})")
681
+ return True
682
+
683
+ def write_stdin(self, name: str, data: dict) -> bool:
684
+ """Write a structured JSON message to a module's stdin.
685
+ Used by Launcher to send additional messages after boot_info (e.g. launcher_ws_token).
686
+ Returns True on success.
687
+ """
688
+ proc = self._processes.get(name)
689
+ if not proc or not proc.stdin or proc.stdin.closed:
690
+ return False
691
+ try:
692
+ proc.stdin.write(json.dumps(data).encode() + b"\n")
693
+ proc.stdin.flush()
694
+ return True
695
+ except Exception as e:
696
+ print(f"[launcher] 警告: 向 {name} 写入 stdin 失败: {e}")
697
+ return False
698
+
699
+ def close_stdio(self, name: str):
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.
704
+ """
705
+ proc = self._processes.get(name)
706
+ if not proc:
707
+ return
708
+ if proc.stdin and not proc.stdin.closed:
709
+ try:
710
+ proc.stdin.close()
711
+ except Exception:
712
+ pass
713
+
714
+ def _build_cmd(self, info) -> list:
715
+ """Build the command list based on module runtime.
716
+
717
+ For python/node: if entry starts with '-m ', use package mode (e.g. python -m pkg).
718
+ Otherwise use script file mode (e.g. python entry.py).
719
+ """
720
+ if info.launch.cmd:
721
+ return list(info.launch.cmd)
722
+
723
+ if info.runtime == "python":
724
+ if info.entry.startswith("-m "):
725
+ return [sys.executable] + info.entry.split()
726
+ return [sys.executable, os.path.join(info.module_dir, info.entry)]
727
+ elif info.runtime == "node":
728
+ if info.entry.startswith("-e ") or info.entry.startswith("--eval "):
729
+ return ["node"] + info.entry.split()
730
+ return ["node", os.path.join(info.module_dir, info.entry)]
731
+ elif info.runtime == "binary":
732
+ return [os.path.join(info.module_dir, info.entry)]
733
+ else:
734
+ print(f"[launcher] 警告: 未知运行时 '{info.runtime}',模块 {info.name}")
735
+ return []
736
+
737
+ def _read_stdout(self, name: str, proc: subprocess.Popen):
738
+ """Read subprocess stdout line by line.
739
+ Lines containing a JSON object with "kite" field are dispatched as structured messages.
740
+ All other lines are printed as log output with module name prefix.
741
+ """
742
+ try:
743
+ for line in iter(proc.stdout.readline, b""):
744
+ text = line.decode("utf-8", errors="replace").rstrip()
745
+ if not text:
746
+ continue
747
+ # Try to parse as structured kite message
748
+ if self._on_kite_message and text.startswith("{"):
749
+ try:
750
+ msg = json.loads(text)
751
+ if isinstance(msg, dict) and "kite" in msg:
752
+ self._on_kite_message(name, msg)
753
+ continue
754
+ except (json.JSONDecodeError, KeyError):
755
+ pass
756
+ # Regular log line — avoid double prefix if module already added [name]
757
+ if text.startswith(f"[{name}]"):
758
+ print(text)
759
+ else:
760
+ print(f"[{name}] {text}")
761
+ except Exception:
762
+ pass
763
+
764
+ # ── Stop module ──
765
+
766
+ def stop_module(self, name: str, timeout: float = 5) -> bool:
767
+ """Stop a module by name. Returns True if stopped successfully."""
768
+ proc = self._processes.get(name)
769
+ if not proc or proc.poll() is not None:
770
+ self._processes.pop(name, None)
771
+ self._records.pop(name, None)
772
+ return True
773
+
774
+ # Close stdio before terminating
775
+ self.close_stdio(name)
776
+
777
+ print(f"[launcher] 正在停止 {name} (PID {proc.pid})...")
778
+ proc.terminate()
779
+ try:
780
+ proc.wait(timeout=timeout)
781
+ except subprocess.TimeoutExpired:
782
+ print(f"[launcher] {name} 在 {timeout}s 内未退出,强制终止")
783
+ proc.kill()
784
+ try:
785
+ proc.wait(timeout=3)
786
+ except subprocess.TimeoutExpired:
787
+ self._force_kill(proc.pid)
788
+
789
+ self._processes.pop(name, None)
790
+ self._records.pop(name, None)
791
+ print(f"[launcher] 已停止 {name}")
792
+ return True
793
+
794
+ def stop_all(self, timeout: float = 10):
795
+ """Stop all managed processes. Terminate first, force kill after timeout."""
796
+ if not self._processes:
797
+ return
798
+
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)}")
808
+
809
+ # Phase 1: close stdio and send terminate to all
810
+ for name in alive:
811
+ self.close_stdio(name)
812
+ proc = self._processes.get(name)
813
+ if proc and proc.poll() is None:
814
+ proc.terminate()
815
+
816
+ # Phase 2: wait for all to exit
817
+ deadline = time.time() + timeout
818
+ for name in alive:
819
+ proc = self._processes.get(name)
820
+ if not proc:
821
+ continue
822
+ remaining = max(0.1, deadline - time.time())
823
+ try:
824
+ proc.wait(timeout=remaining)
825
+ except subprocess.TimeoutExpired:
826
+ pass
827
+
828
+ # Phase 3: force kill survivors
829
+ for name in alive:
830
+ proc = self._processes.get(name)
831
+ if proc and proc.poll() is None:
832
+ print(f"[launcher] 强制终止 {name} (PID {proc.pid})")
833
+ proc.kill()
834
+ try:
835
+ proc.wait(timeout=3)
836
+ except subprocess.TimeoutExpired:
837
+ self._force_kill(proc.pid)
838
+
839
+ self._processes.clear()
840
+ self._records.clear()
841
+ print("[launcher] 所有模块已停止")
842
+
843
+ # ── Persistence & monitoring ──
844
+
845
+ def persist_records(self):
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
+ }
853
+ self._write_records_file(data)
854
+
855
+ def check_exited(self) -> list[tuple[str, int]]:
856
+ """Non-blocking check for exited child processes. Returns [(name, returncode)]."""
857
+ exited = []
858
+ for name in list(self._processes.keys()):
859
+ proc = self._processes[name]
860
+ rc = proc.poll()
861
+ if rc is not None:
862
+ exited.append((name, rc))
863
+ self._processes.pop(name, None)
864
+ self._records.pop(name, None)
865
+ return exited
866
+
867
+ def get_record(self, name: str) -> ProcessRecord | None:
868
+ return self._records.get(name)
869
+
870
+ def is_running(self, name: str) -> bool:
871
+ proc = self._processes.get(name)
872
+ return proc is not None and proc.poll() is None
873
+
874
+ def _write_records_file(self, data):
875
+ """Write data to the instance's processes JSON file."""
876
+ try:
877
+ with open(self.records_path, "w", encoding="utf-8") as f:
878
+ json.dump(data, f, indent=2)
879
+ except Exception as e:
880
+ print(f"[launcher] 警告: 写入 {os.path.basename(self.records_path)} 失败: {e}")