@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.
Files changed (77) hide show
  1. package/core/event_hub/entry.py +305 -26
  2. package/core/event_hub/hub.py +8 -0
  3. package/core/event_hub/server.py +80 -17
  4. package/core/kite_log.py +241 -0
  5. package/core/launcher/entry.py +978 -284
  6. package/core/launcher/process_manager.py +456 -46
  7. package/core/registry/entry.py +272 -3
  8. package/core/registry/server.py +339 -289
  9. package/core/registry/store.py +10 -4
  10. package/extensions/agents/__init__.py +1 -0
  11. package/extensions/agents/assistant/__init__.py +1 -0
  12. package/extensions/agents/assistant/entry.py +380 -0
  13. package/extensions/agents/assistant/module.md +22 -0
  14. package/extensions/agents/assistant/server.py +236 -0
  15. package/extensions/channels/__init__.py +1 -0
  16. package/extensions/channels/acp_channel/__init__.py +1 -0
  17. package/extensions/channels/acp_channel/entry.py +380 -0
  18. package/extensions/channels/acp_channel/module.md +22 -0
  19. package/extensions/channels/acp_channel/server.py +236 -0
  20. package/extensions/event_hub_bench/entry.py +664 -379
  21. package/extensions/event_hub_bench/module.md +2 -1
  22. package/extensions/services/backup/__init__.py +1 -0
  23. package/extensions/services/backup/entry.py +380 -0
  24. package/extensions/services/backup/module.md +22 -0
  25. package/extensions/services/backup/server.py +244 -0
  26. package/extensions/services/model_service/__init__.py +1 -0
  27. package/extensions/services/model_service/entry.py +380 -0
  28. package/extensions/services/model_service/module.md +22 -0
  29. package/extensions/services/model_service/server.py +236 -0
  30. package/extensions/services/watchdog/entry.py +460 -147
  31. package/extensions/services/watchdog/module.md +3 -0
  32. package/extensions/services/watchdog/monitor.py +128 -13
  33. package/extensions/services/watchdog/server.py +75 -13
  34. package/extensions/services/web/__init__.py +1 -0
  35. package/extensions/services/web/config.yaml +149 -0
  36. package/extensions/services/web/entry.py +487 -0
  37. package/extensions/services/web/module.md +24 -0
  38. package/extensions/services/web/routes/__init__.py +1 -0
  39. package/extensions/services/web/routes/routes_call.py +189 -0
  40. package/extensions/services/web/routes/routes_config.py +512 -0
  41. package/extensions/services/web/routes/routes_contacts.py +98 -0
  42. package/extensions/services/web/routes/routes_devlog.py +99 -0
  43. package/extensions/services/web/routes/routes_phone.py +81 -0
  44. package/extensions/services/web/routes/routes_sms.py +48 -0
  45. package/extensions/services/web/routes/routes_stats.py +17 -0
  46. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  47. package/extensions/services/web/routes/schemas.py +216 -0
  48. package/extensions/services/web/server.py +332 -0
  49. package/extensions/services/web/static/css/style.css +1064 -0
  50. package/extensions/services/web/static/index.html +1445 -0
  51. package/extensions/services/web/static/js/app.js +4671 -0
  52. package/extensions/services/web/vendor/__init__.py +1 -0
  53. package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
  54. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  55. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  56. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  57. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  58. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  59. package/extensions/services/web/vendor/config.py +139 -0
  60. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  61. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  62. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  63. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  64. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  65. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  66. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  67. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  68. package/extensions/services/web/vendor/storage/identity.py +312 -0
  69. package/extensions/services/web/vendor/storage/store.py +507 -0
  70. package/extensions/services/web/vendor/task/__init__.py +0 -0
  71. package/extensions/services/web/vendor/task/manager.py +864 -0
  72. package/extensions/services/web/vendor/task/models.py +45 -0
  73. package/extensions/services/web/vendor/task/webhook.py +263 -0
  74. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  75. package/extensions/services/web/vendor/tools/registry.py +321 -0
  76. package/main.py +230 -90
  77. 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
- self.data_dir = os.environ.get("KITE_MODULE_DATA") or os.path.join(os.environ["KITE_INSTANCE_DIR"], "launcher")
49
- self.records_path = os.path.join(self.data_dir, "processes.json")
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
- os.makedirs(self.data_dir, exist_ok=True)
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
- """Read processes.json in current instance dir, kill leftovers from previous runs."""
58
- if not os.path.isfile(self.records_path):
59
- return
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
- with open(self.records_path, "r", encoding="utf-8") as f:
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(self.records_path)
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
- for entry in saved:
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(self.records_path)
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
- try:
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, text=True, timeout=8,
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, text=True, timeout=5,
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, text=True, timeout=5)
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 and stdout for a module. Called by Launcher after module.ready → module.started.
300
- stdout reader thread will exit naturally when stdout is closed.
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
- names = list(self._processes.keys())
402
- print(f"[launcher] 正在停止所有模块: {', '.join(names)}")
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 names:
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 names:
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 names:
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 = [asdict(r) for r in self._records.values()]
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: list):
465
- """Write data list to processes.json."""
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] 警告: 写入 processes.json 失败: {e}")
880
+ print(f"[launcher] 警告: 写入 {os.path.basename(self.records_path)} 失败: {e}")