@agentunion/kite 1.0.6 → 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 (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
@@ -1,16 +1,27 @@
1
1
  """
2
2
  Manage child process lifecycle: start, stop, monitor, cleanup leftovers.
3
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
4
9
  """
5
10
 
11
+ import glob
6
12
  import json
7
13
  import os
14
+ import re
8
15
  import subprocess
9
16
  import sys
10
17
  import threading
11
18
  import time
12
19
  from dataclasses import dataclass, asdict
20
+ from typing import Callable
13
21
 
22
+ if sys.platform == "win32":
23
+ import ctypes
24
+ from ctypes import wintypes
14
25
 
15
26
  IS_WINDOWS = sys.platform == "win32"
16
27
  IS_MACOS = sys.platform == "darwin"
@@ -28,105 +39,322 @@ class ProcessRecord:
28
39
  class ProcessManager:
29
40
  """Manage child processes for all Kite modules."""
30
41
 
31
- def __init__(self, project_root: str, kite_token: str, instance_id: str = ""):
32
- self.project_root = project_root
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
+ """
33
49
  self.kite_token = kite_token
34
50
  self.instance_id = instance_id or str(os.getpid())
35
- # Use centralized data directory management
36
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
37
- from data_dir import get_launcher_data_dir
38
- self.data_dir = get_launcher_data_dir()
39
- self.records_path = os.path.join(self.data_dir, f"processes_{self.instance_id}.json")
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")
40
60
  self._processes: dict[str, subprocess.Popen] = {} # name -> Popen
41
61
  self._records: dict[str, ProcessRecord] = {} # name -> record
42
- os.makedirs(self.data_dir, exist_ok=True)
43
62
 
44
- # ── Leftover cleanup ──
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
45
67
 
46
- def cleanup_leftovers(self):
47
- """Scan all processes_*.json files, kill leftovers from dead instances, preserve live ones."""
48
- import glob
49
-
50
- # Also clean legacy processes.json (pre-multi-instance)
51
- legacy = os.path.join(self.data_dir, "processes.json")
52
- patterns = [
53
- os.path.join(self.data_dir, "processes_*.json"),
54
- ]
55
- files = []
56
- for p in patterns:
57
- files.extend(glob.glob(p))
58
- if os.path.isfile(legacy):
59
- files.append(legacy)
60
-
61
- registry_data_dir = os.path.join(self.project_root, "core", "registry", "data")
62
- # Also check user home registry data dir
63
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
64
- from data_dir import get_registry_data_dir
65
- registry_data_dir_alt = get_registry_data_dir()
66
-
67
- for path in files:
68
- if path == self.records_path:
69
- continue # skip our own instance
70
-
71
- # If this is an instance file, check if that Launcher is still alive
72
- basename = os.path.basename(path)
73
- if basename.startswith("processes_") and basename.endswith(".json"):
74
- inst_id = basename[len("processes_"):-len(".json")]
75
- try:
76
- if self._is_pid_alive(int(inst_id)):
77
- continue # that Launcher is still running, skip
78
- except (ValueError, TypeError):
79
- pass
68
+ @property
69
+ def instance_num(self) -> int:
70
+ """Return the instance number (1-based)."""
71
+ return self._instance_num
80
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 = ""
81
93
  try:
82
- with open(path, "r", encoding="utf-8") as f:
83
- saved = json.load(f)
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)
84
102
  except Exception:
85
- os.remove(path)
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):
86
141
  continue
87
142
 
88
- for entry in saved:
89
- pid = entry.get("pid", 0)
90
- cmd = entry.get("cmd", [])
91
- name = entry.get("name", "?")
92
- if not pid:
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:
93
158
  continue
94
- if self._is_pid_alive(pid) and self._cmd_matches(pid, cmd):
95
- print(f"[launcher] Killing leftover process: {name} (PID {pid})")
96
- self._force_kill(pid)
97
- else:
98
- print(f"[launcher] Leftover {name} (PID {pid}) already gone")
99
-
100
- os.remove(path)
101
-
102
- # Remove corresponding port file
103
- basename = os.path.basename(path)
104
- if basename.startswith("processes_") and basename.endswith(".json"):
105
- inst_id = basename[len("processes_"):-len(".json")]
106
- port_file = os.path.join(registry_data_dir, f"port_{inst_id}.txt")
107
- if os.path.isfile(port_file):
108
- os.remove(port_file)
109
- # Also check alt location
110
- port_file_alt = os.path.join(registry_data_dir_alt, f"port_{inst_id}.txt")
111
- if os.path.isfile(port_file_alt):
112
- os.remove(port_file_alt)
113
-
114
- # Also clean legacy port.txt
115
- legacy_port = os.path.join(registry_data_dir, "port.txt")
116
- if os.path.isfile(legacy_port):
117
- os.remove(legacy_port)
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
118
349
 
119
350
  def _is_pid_alive(self, pid: int) -> bool:
120
- """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
+ """
121
356
  if IS_WINDOWS:
122
- try:
123
- result = subprocess.run(
124
- ["tasklist", "/FI", f"PID eq {pid}", "/NH"],
125
- capture_output=True, text=True, timeout=5,
126
- )
127
- return str(pid) in result.stdout
128
- except Exception:
129
- return False
357
+ return self._win_is_pid_alive_ctypes(pid)
130
358
  else:
131
359
  try:
132
360
  os.kill(pid, 0)
@@ -134,6 +362,95 @@ class ProcessManager:
134
362
  except OSError:
135
363
  return False
136
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
+
137
454
  def _cmd_matches(self, pid: int, expected_cmd: list) -> bool:
138
455
  """Verify the process command line matches what we expect (prevent killing recycled PIDs).
139
456
  Uses multi-strategy fallback per platform for robustness.
@@ -181,9 +498,9 @@ class ProcessManager:
181
498
  r = subprocess.run(
182
499
  ["powershell", "-NoProfile", "-Command",
183
500
  f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"],
184
- capture_output=True, text=True, timeout=8,
501
+ capture_output=True, timeout=8,
185
502
  )
186
- return r.stdout.strip()
503
+ return r.stdout.decode(errors='replace').strip()
187
504
  except Exception:
188
505
  return ""
189
506
 
@@ -193,22 +510,31 @@ class ProcessManager:
193
510
  r = subprocess.run(
194
511
  ["wmic", "process", "where", f"ProcessId={pid}",
195
512
  "get", "CommandLine", "/value"],
196
- capture_output=True, text=True, timeout=5,
513
+ capture_output=True, timeout=5,
197
514
  )
198
- return r.stdout.strip()
515
+ return r.stdout.decode(errors='replace').strip()
199
516
  except Exception:
200
517
  return ""
201
518
 
202
519
  def _exe_name_matches(self, pid: int, expected_cmd: list) -> bool:
203
- """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
+ """
204
525
  exe = os.path.basename(expected_cmd[0]).lower()
205
526
  if exe.endswith(".exe"):
206
527
  exe = exe[:-4]
207
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
208
534
  try:
209
535
  r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
210
- capture_output=True, text=True, timeout=5)
211
- return exe in r.stdout.lower()
536
+ capture_output=True, timeout=5)
537
+ return exe in r.stdout.decode(errors='replace').lower()
212
538
  except Exception:
213
539
  return False
214
540
  else:
@@ -219,6 +545,50 @@ class ProcessManager:
219
545
  except Exception:
220
546
  return False
221
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
+
222
592
  def _force_kill(self, pid: int):
223
593
  """Force kill a process by PID."""
224
594
  try:
@@ -229,13 +599,14 @@ class ProcessManager:
229
599
  import signal
230
600
  os.kill(pid, signal.SIGKILL)
231
601
  except Exception as e:
232
- print(f"[launcher] WARNING: failed to kill PID {pid}: {e}")
602
+ print(f"[launcher] 警告: 强制终止 PID {pid} 失败: {e}")
233
603
 
234
604
  # ── Start module ──
235
605
 
236
606
  def start_module(self, info, boot_info: dict = None) -> bool:
237
607
  """Start a module as a subprocess. Returns True on success.
238
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.
239
610
  """
240
611
  from .module_scanner import ModuleInfo
241
612
  info: ModuleInfo
@@ -243,17 +614,18 @@ class ProcessManager:
243
614
  if info.name in self._processes:
244
615
  proc = self._processes[info.name]
245
616
  if proc.poll() is None:
246
- print(f"[launcher] {info.name} already running (PID {proc.pid})")
617
+ print(f"[launcher] {info.name} 已在运行 (PID {proc.pid})")
247
618
  return True
248
619
 
249
620
  cmd = self._build_cmd(info)
250
621
  if not cmd:
251
- print(f"[launcher] ERROR: cannot build command for {info.name}")
622
+ print(f"[launcher] 错误: 无法构建 {info.name} 的启动命令")
252
623
  return False
253
624
 
254
625
  env = os.environ.copy()
255
626
  env["PYTHONUTF8"] = "1" # Force UTF-8 in child Python processes
256
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)
257
629
  if info.launch.env:
258
630
  env.update(info.launch.env)
259
631
 
@@ -278,17 +650,16 @@ class ProcessManager:
278
650
  creationflags=creation_flags,
279
651
  )
280
652
  except Exception as e:
281
- print(f"[launcher] ERROR: failed to start {info.name}: {e}")
653
+ print(f"[launcher] 错误: 启动 {info.name} 失败: {e}")
282
654
  return False
283
655
 
284
- # Write boot_info to stdin then close
656
+ # Write boot_info to stdin keep stdin open for additional messages
285
657
  if use_stdin and proc.stdin:
286
658
  try:
287
659
  proc.stdin.write(json.dumps(boot_info).encode() + b"\n")
288
660
  proc.stdin.flush()
289
- proc.stdin.close()
290
661
  except Exception as e:
291
- print(f"[launcher] WARNING: failed to write boot_info to {info.name}: {e}")
662
+ print(f"[launcher] 警告: {info.name} 写入 boot_info 失败: {e}")
292
663
 
293
664
  self._processes[info.name] = proc
294
665
  self._records[info.name] = ProcessRecord(
@@ -299,16 +670,47 @@ class ProcessManager:
299
670
  started_at=time.time(),
300
671
  )
301
672
 
302
- # Daemon thread to read stdout and prefix with module name
673
+ # Daemon thread to read stdout: structured messages + log lines
303
674
  t = threading.Thread(
304
675
  target=self._read_stdout, args=(info.name, proc),
305
676
  daemon=True,
306
677
  )
307
678
  t.start()
308
679
 
309
- print(f"[launcher] Started {info.name} (PID {proc.pid})")
680
+ print(f"[launcher] 已启动 {info.name} (PID {proc.pid})")
310
681
  return True
311
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
+
312
714
  def _build_cmd(self, info) -> list:
313
715
  """Build the command list based on module runtime.
314
716
 
@@ -329,16 +731,32 @@ class ProcessManager:
329
731
  elif info.runtime == "binary":
330
732
  return [os.path.join(info.module_dir, info.entry)]
331
733
  else:
332
- print(f"[launcher] WARNING: unknown runtime '{info.runtime}' for {info.name}")
734
+ print(f"[launcher] 警告: 未知运行时 '{info.runtime}',模块 {info.name}")
333
735
  return []
334
736
 
335
- @staticmethod
336
- def _read_stdout(name: str, proc: subprocess.Popen):
337
- """Read subprocess stdout line by line, prefix with module name."""
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
+ """
338
742
  try:
339
743
  for line in iter(proc.stdout.readline, b""):
340
744
  text = line.decode("utf-8", errors="replace").rstrip()
341
- if text:
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:
342
760
  print(f"[{name}] {text}")
343
761
  except Exception:
344
762
  pass
@@ -353,12 +771,15 @@ class ProcessManager:
353
771
  self._records.pop(name, None)
354
772
  return True
355
773
 
356
- print(f"[launcher] Stopping {name} (PID {proc.pid})...")
774
+ # Close stdio before terminating
775
+ self.close_stdio(name)
776
+
777
+ print(f"[launcher] 正在停止 {name} (PID {proc.pid})...")
357
778
  proc.terminate()
358
779
  try:
359
780
  proc.wait(timeout=timeout)
360
781
  except subprocess.TimeoutExpired:
361
- print(f"[launcher] {name} did not exit in {timeout}s, force killing")
782
+ print(f"[launcher] {name} {timeout}s 内未退出,强制终止")
362
783
  proc.kill()
363
784
  try:
364
785
  proc.wait(timeout=3)
@@ -367,7 +788,7 @@ class ProcessManager:
367
788
 
368
789
  self._processes.pop(name, None)
369
790
  self._records.pop(name, None)
370
- print(f"[launcher] Stopped {name}")
791
+ print(f"[launcher] 已停止 {name}")
371
792
  return True
372
793
 
373
794
  def stop_all(self, timeout: float = 10):
@@ -375,18 +796,26 @@ class ProcessManager:
375
796
  if not self._processes:
376
797
  return
377
798
 
378
- names = list(self._processes.keys())
379
- print(f"[launcher] Stopping all modules: {', '.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)}")
380
808
 
381
- # Phase 1: send terminate to all
382
- for name in names:
809
+ # Phase 1: close stdio and send terminate to all
810
+ for name in alive:
811
+ self.close_stdio(name)
383
812
  proc = self._processes.get(name)
384
813
  if proc and proc.poll() is None:
385
814
  proc.terminate()
386
815
 
387
816
  # Phase 2: wait for all to exit
388
817
  deadline = time.time() + timeout
389
- for name in names:
818
+ for name in alive:
390
819
  proc = self._processes.get(name)
391
820
  if not proc:
392
821
  continue
@@ -397,10 +826,10 @@ class ProcessManager:
397
826
  pass
398
827
 
399
828
  # Phase 3: force kill survivors
400
- for name in names:
829
+ for name in alive:
401
830
  proc = self._processes.get(name)
402
831
  if proc and proc.poll() is None:
403
- print(f"[launcher] Force killing {name} (PID {proc.pid})")
832
+ print(f"[launcher] 强制终止 {name} (PID {proc.pid})")
404
833
  proc.kill()
405
834
  try:
406
835
  proc.wait(timeout=3)
@@ -409,13 +838,18 @@ class ProcessManager:
409
838
 
410
839
  self._processes.clear()
411
840
  self._records.clear()
412
- print("[launcher] All modules stopped")
841
+ print("[launcher] 所有模块已停止")
413
842
 
414
843
  # ── Persistence & monitoring ──
415
844
 
416
845
  def persist_records(self):
417
- """Write current process records to processes.json."""
418
- 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
+ }
419
853
  self._write_records_file(data)
420
854
 
421
855
  def check_exited(self) -> list[tuple[str, int]]:
@@ -437,10 +871,10 @@ class ProcessManager:
437
871
  proc = self._processes.get(name)
438
872
  return proc is not None and proc.poll() is None
439
873
 
440
- def _write_records_file(self, data: list):
441
- """Write data list to processes.json."""
874
+ def _write_records_file(self, data):
875
+ """Write data to the instance's processes JSON file."""
442
876
  try:
443
877
  with open(self.records_path, "w", encoding="utf-8") as f:
444
878
  json.dump(data, f, indent=2)
445
879
  except Exception as e:
446
- print(f"[launcher] WARNING: failed to write processes.json: {e}")
880
+ print(f"[launcher] 警告: 写入 {os.path.basename(self.records_path)} 失败: {e}")