@agentunion/kite 1.3.1 → 1.4.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 (78) hide show
  1. package/CHANGELOG.md +287 -1
  2. package/cli.js +76 -0
  3. package/extensions/agents/assistant/entry.py +111 -1
  4. package/extensions/agents/assistant/server.py +263 -197
  5. package/extensions/channels/acp_channel/entry.py +111 -1
  6. package/extensions/channels/acp_channel/module.md +23 -22
  7. package/extensions/channels/acp_channel/server.py +263 -197
  8. package/extensions/event_hub_bench/entry.py +107 -1
  9. package/extensions/services/backup/entry.py +408 -72
  10. package/extensions/services/backup/module.md +24 -22
  11. package/extensions/services/model_service/entry.py +255 -71
  12. package/extensions/services/model_service/module.md +21 -22
  13. package/extensions/services/watchdog/entry.py +344 -90
  14. package/extensions/services/watchdog/monitor.py +237 -21
  15. package/extensions/services/web/WEBSOCKET_STATUS.md +143 -0
  16. package/extensions/services/web/config_example.py +35 -0
  17. package/extensions/services/web/config_loader.py +110 -0
  18. package/extensions/services/web/entry.py +114 -26
  19. package/extensions/services/web/module.md +35 -24
  20. package/extensions/services/web/pairing.py +250 -0
  21. package/extensions/services/web/pairing_codes.jsonl +16 -0
  22. package/extensions/services/web/relay.py +643 -0
  23. package/extensions/services/web/relay_config.json5 +67 -0
  24. package/extensions/services/web/routes/routes_management_ws.py +127 -0
  25. package/extensions/services/web/routes/routes_rpc.py +89 -0
  26. package/extensions/services/web/routes/routes_test.py +61 -0
  27. package/extensions/services/web/server.py +445 -99
  28. package/extensions/services/web/static/css/style.css +138 -2
  29. package/extensions/services/web/static/index.html +295 -2
  30. package/extensions/services/web/static/js/app.js +1579 -5
  31. package/extensions/services/web/static/js/kernel-client-example.js +161 -0
  32. package/extensions/services/web/static/js/kernel-client.js +383 -0
  33. package/extensions/services/web/static/js/registry-tests.js +558 -0
  34. package/extensions/services/web/static/js/token-manager.js +175 -0
  35. package/extensions/services/web/static/pairing.html +248 -0
  36. package/extensions/services/web/static/test_registry.html +262 -0
  37. package/extensions/services/web/web_config.json5 +29 -0
  38. package/kernel/entry.py +120 -32
  39. package/kernel/event_hub.py +159 -16
  40. package/kernel/module.md +36 -33
  41. package/kernel/registry_store.py +70 -20
  42. package/kernel/rpc_router.py +134 -57
  43. package/kernel/server.py +292 -15
  44. package/kite_cli/__init__.py +3 -0
  45. package/kite_cli/__main__.py +5 -0
  46. package/kite_cli/commands/__init__.py +1 -0
  47. package/kite_cli/commands/clean.py +101 -0
  48. package/kite_cli/commands/doctor.py +35 -0
  49. package/kite_cli/commands/history.py +111 -0
  50. package/kite_cli/commands/info.py +96 -0
  51. package/kite_cli/commands/install.py +313 -0
  52. package/kite_cli/commands/list.py +143 -0
  53. package/kite_cli/commands/log.py +81 -0
  54. package/kite_cli/commands/rollback.py +88 -0
  55. package/kite_cli/commands/search.py +73 -0
  56. package/kite_cli/commands/uninstall.py +85 -0
  57. package/kite_cli/commands/update.py +118 -0
  58. package/kite_cli/core/__init__.py +1 -0
  59. package/kite_cli/core/checker.py +142 -0
  60. package/kite_cli/core/dependency.py +229 -0
  61. package/kite_cli/core/downloader.py +209 -0
  62. package/kite_cli/core/install_info.py +40 -0
  63. package/kite_cli/core/tool_installer.py +397 -0
  64. package/kite_cli/core/validator.py +78 -0
  65. package/kite_cli/main.py +289 -0
  66. package/kite_cli/utils/__init__.py +1 -0
  67. package/kite_cli/utils/i18n.py +252 -0
  68. package/kite_cli/utils/interactive.py +63 -0
  69. package/kite_cli/utils/operation_log.py +77 -0
  70. package/kite_cli/utils/paths.py +34 -0
  71. package/kite_cli/utils/version.py +308 -0
  72. package/launcher/count_lines.py +34 -0
  73. package/launcher/entry.py +905 -166
  74. package/launcher/logging_setup.py +104 -0
  75. package/launcher/module.md +37 -37
  76. package/launcher/process_manager.py +12 -1
  77. package/package.json +2 -1
  78. package/scripts/plan_manager.py +315 -0
@@ -24,6 +24,7 @@ class RegistryStore:
24
24
  self.ttl = 60 # seconds before marking offline
25
25
  self.heartbeat_interval = 30
26
26
  self.is_debug = os.environ.get("KITE_DEBUG") == "1"
27
+ self.last_update_time = time.time() # Global registry update timestamp
27
28
 
28
29
  # ── Token verification ──
29
30
 
@@ -57,58 +58,112 @@ class RegistryStore:
57
58
  _REQUIRED_FIELDS = ("module_id", "module_type") # api_endpoint now optional
58
59
 
59
60
  def register_module(self, data: dict) -> dict:
60
- """Register or update a module. Idempotent — same module_id overwrites."""
61
+ """Register or update a module. Idempotent — same module_id overwrites.
62
+ Returns dict with ttl, heartbeat_interval, and changed flag on success, raises exception on failure."""
61
63
  # Validate required fields
62
64
  missing = [f for f in self._REQUIRED_FIELDS if not data.get(f)]
63
65
  if missing:
64
- return {"ok": False, "error": f"Missing required fields: {', '.join(missing)}"}
66
+ raise ValueError(f"Missing required fields: {', '.join(missing)}")
65
67
 
66
68
  mid = data["module_id"]
67
69
 
68
- # Warn on tool name conflicts
70
+ # Validate nested dict structure for lookup-able fields
71
+ # Only check known wrapper fields (tools, hooks, events_publish)
72
+ # RPC methods are registered directly at top level with their actual names
73
+ # events_subscribe is a list, not a dict
74
+ for field_name in ["tools", "hooks", "events_publish"]:
75
+ field_value = data.get(field_name)
76
+ if field_value is not None:
77
+ if not isinstance(field_value, dict):
78
+ raise TypeError(f"Field '{field_name}' must be a dict (nested structure), got {type(field_value).__name__}")
79
+ # Check if it's a flat dict with dot-notation keys (common mistake)
80
+ if any("." in str(k) for k in field_value.keys()):
81
+ raise ValueError(
82
+ f"Field '{field_name}' contains dot-notation keys (e.g., 'module.config.get'). "
83
+ f"Use nested dict structure instead: {{'module': {{'config': {{'get': ...}}}}}}. "
84
+ f"See docs/模块开发指南.md section 4.3 for correct format."
85
+ )
86
+
87
+ # Warn on tool name conflicts (skip 'rpc' — it's a standard tool every module registers)
69
88
  new_tools = data.get("tools", {})
70
89
  if isinstance(new_tools, dict):
71
90
  for tool_name in new_tools:
91
+ if tool_name == "rpc":
92
+ continue
72
93
  for other_mid, other_data in self.modules.items():
73
94
  if other_mid == mid:
74
95
  continue
75
96
  if tool_name in other_data.get("tools", {}):
76
97
  print(f"[kernel] WARNING: tool '{tool_name}' registered by both '{other_mid}' and '{mid}'")
77
98
 
99
+ # Check if content changed (compare with existing registration)
100
+ changed = True
101
+ old_record = self.modules.get(mid)
102
+ if old_record:
103
+ # Strip action field from new data
104
+ new_record = {k: v for k, v in data.items() if k != "action"}
105
+ # Compare without status and registered_at fields
106
+ old_data = {k: v for k, v in old_record.items() if k not in ("status", "registered_at")}
107
+ changed = (new_record != old_data)
108
+
78
109
  # Strip action field — it's a request verb, not part of the registration payload
79
110
  record = {k: v for k, v in data.items() if k != "action"}
80
- record["status"] = "online"
111
+ record["status"] = "registered" # State machine: connected → registered (via register RPC)
81
112
  record["registered_at"] = time.time()
82
113
  self.modules[mid] = record
83
114
  self.heartbeats[mid] = time.time()
84
- return {"ok": True, "ttl": self.ttl, "heartbeat_interval": self.heartbeat_interval}
115
+
116
+ # Update global timestamp if content changed
117
+ if changed:
118
+ self.last_update_time = time.time()
119
+
120
+ return {"ttl": self.ttl, "heartbeat_interval": self.heartbeat_interval, "changed": changed}
85
121
 
86
122
  def deregister_module(self, module_id: str) -> dict:
87
- """Remove a module record immediately."""
123
+ """Remove a module record immediately. Returns empty dict."""
88
124
  self.modules.pop(module_id, None)
89
125
  self.heartbeats.pop(module_id, None)
90
- return {"ok": True}
126
+ # Update global timestamp
127
+ self.last_update_time = time.time()
128
+ return {}
91
129
 
92
130
  def heartbeat(self, module_id: str) -> dict:
93
- """Renew heartbeat for a module."""
131
+ """Renew heartbeat for a module. Returns empty dict on success, raises exception on failure."""
94
132
  if module_id not in self.modules:
95
- return {"ok": False, "error": "module not registered"}
133
+ raise KeyError(f"Module '{module_id}' not registered")
96
134
  self.heartbeats[module_id] = time.time()
97
- self.modules[module_id]["status"] = "online"
98
- return {"ok": True}
135
+ # Don't change status heartbeat just keeps alive, doesn't upgrade state
136
+ return {}
137
+
138
+ def set_connected(self, module_id: str):
139
+ """Mark a module as connected (WS established, not yet registered).
140
+ Only upgrades from offline; doesn't downgrade from registered/ready."""
141
+ if module_id in self.modules:
142
+ if self.modules[module_id].get("status") == "offline":
143
+ self.modules[module_id]["status"] = "connected"
144
+
145
+ def set_ready(self, module_id: str):
146
+ """Mark a module as ready (received module.ready event)."""
147
+ if module_id in self.modules:
148
+ self.modules[module_id]["status"] = "ready"
99
149
 
100
150
  def set_offline(self, module_id: str):
101
- """Mark a module as offline (called on WebSocket disconnect)."""
151
+ """Mark a module as offline (called after debounce on WebSocket disconnect)."""
102
152
  if module_id in self.modules:
103
153
  self.modules[module_id]["status"] = "offline"
104
154
 
155
+ def is_ready(self, module_id: str) -> bool:
156
+ """Check if a module is in ready state."""
157
+ mod = self.modules.get(module_id)
158
+ return mod is not None and mod.get("status") == "ready"
159
+
105
160
  def check_ttl(self) -> list[str]:
106
161
  """Mark modules as offline if heartbeat expired. Returns list of newly-offline module_ids."""
107
162
  now = time.time()
108
163
  expired = []
109
164
  for mid, last in list(self.heartbeats.items()):
110
165
  if mid in self.modules and now - last > self.ttl:
111
- if self.modules[mid].get("status") != "offline":
166
+ if self.modules[mid].get("status") not in ("offline",):
112
167
  self.modules[mid]["status"] = "offline"
113
168
  expired.append(mid)
114
169
  return expired
@@ -160,17 +215,15 @@ class RegistryStore:
160
215
  def lookup(self, field: str = None, module: str = None, value: str = None) -> list[dict]:
161
216
  """
162
217
  Search across all online modules. All three params support glob patterns.
163
- Returns list of {field, module, api_endpoint, value}.
218
+ Returns list of {field, module, value}.
164
219
  """
165
220
  results = []
166
221
  for mid, data in self.modules.items():
167
- if data.get("status") != "online":
222
+ if data.get("status") not in ("registered", "ready"):
168
223
  continue
169
224
  if module and not fnmatch.fnmatch(mid, module):
170
225
  continue
171
226
 
172
- api_ep = data.get("api_endpoint", "")
173
-
174
227
  if field:
175
228
  matches = self._match_fields(data, field)
176
229
  for fpath, fval in matches:
@@ -179,7 +232,6 @@ class RegistryStore:
179
232
  results.append({
180
233
  "field": fpath,
181
234
  "module": mid,
182
- "api_endpoint": api_ep,
183
235
  "value": fval,
184
236
  })
185
237
  elif value:
@@ -188,14 +240,12 @@ class RegistryStore:
188
240
  results.append({
189
241
  "field": k,
190
242
  "module": mid,
191
- "api_endpoint": api_ep,
192
243
  "value": v,
193
244
  })
194
245
  else:
195
246
  results.append({
196
247
  "field": "module_id",
197
248
  "module": mid,
198
- "api_endpoint": api_ep,
199
249
  "value": mid,
200
250
  })
201
251
 
@@ -73,6 +73,12 @@ class RpcRouter:
73
73
  self.connections = connections
74
74
  self.kernel_server = kernel_server # Direct reference to KernelServer
75
75
 
76
+ # RPC 统计计数器
77
+ self._rpc_total = 0 # 总 RPC 调用次数
78
+ self._rpc_builtin = 0 # 内置方法调用次数
79
+ self._rpc_forwarded = 0 # 转发调用次数
80
+ self._rpc_errors = 0 # 错误次数
81
+
76
82
  # Builtin method dispatch table
77
83
  self.methods: dict[str, callable] = {
78
84
  "registry.register": self._registry_register,
@@ -89,7 +95,6 @@ class RpcRouter:
89
95
  "kernel.health": self._kernel_health,
90
96
  "kernel.generate_tokens": self._kernel_generate_tokens,
91
97
  "kernel.register_tokens": self._kernel_register_tokens,
92
- "kernel.shutdown": self._kernel_shutdown,
93
98
  }
94
99
 
95
100
  # Pending cross-module forwards: internal_id -> PendingForward
@@ -102,6 +107,9 @@ class RpcRouter:
102
107
  method = msg.get("method", "")
103
108
  msg_id = msg.get("id")
104
109
 
110
+ # 统计:总调用次数
111
+ self._rpc_total += 1
112
+
105
113
  # JSON-RPC Notification (no id) — currently not handled from clients
106
114
  if msg_id is None:
107
115
  return
@@ -111,12 +119,27 @@ class RpcRouter:
111
119
  # Builtin method
112
120
  handler = self.methods.get(method)
113
121
  if handler:
122
+ # 统计:内置方法调用
123
+ self._rpc_builtin += 1
114
124
  try:
115
125
  result = await handler(caller_id, params)
116
- await ws.send_text(_result_msg(msg_id, result))
117
126
  except Exception as e:
127
+ # 统计:错误次数
128
+ self._rpc_errors += 1
118
129
  print(f"[kernel] RPC handler error ({method}): {e}")
119
- await ws.send_text(_error_msg(msg_id, INTERNAL_ERROR, str(e)))
130
+ try:
131
+ await ws.send_text(_error_msg(msg_id, INTERNAL_ERROR, str(e)))
132
+ except Exception:
133
+ # Can't send error response, connection closed
134
+ pass
135
+ return
136
+
137
+ # Send result (may fail if connection closed during shutdown)
138
+ try:
139
+ await ws.send_text(_result_msg(msg_id, result))
140
+ except Exception as e:
141
+ # Connection closed during shutdown — this is normal, exit silently
142
+ pass
120
143
  return
121
144
 
122
145
  # Cross-module forward: method prefix is target module_id
@@ -124,15 +147,29 @@ class RpcRouter:
124
147
  if dot_idx > 0:
125
148
  target = method[:dot_idx]
126
149
  if target in self.connections:
150
+ # Check if target module is ready (state machine protection)
151
+ if not self.registry.is_ready(target):
152
+ # 统计:错误次数
153
+ self._rpc_errors += 1
154
+ await ws.send_text(_error_msg(
155
+ msg_id, MODULE_OFFLINE,
156
+ f"Module not ready: {target} (status: {self.registry.modules.get(target, {}).get('status', 'unknown')})"))
157
+ return
158
+ # 统计:转发调用
159
+ self._rpc_forwarded += 1
127
160
  await self._forward(caller_id, ws, msg_id, target, method, params)
128
161
  return
129
162
  # Target not connected — check if registered but offline
130
163
  if target in self.registry.modules:
164
+ # 统计:错误次数
165
+ self._rpc_errors += 1
131
166
  await ws.send_text(_error_msg(
132
167
  msg_id, MODULE_OFFLINE, f"Module offline: {target}"))
133
168
  return
134
169
 
135
170
  # Method not found
171
+ # 统计:错误次数
172
+ self._rpc_errors += 1
136
173
  await ws.send_text(_error_msg(msg_id, METHOD_NOT_FOUND, f"Method not found: {method}"))
137
174
 
138
175
  async def handle_response(self, module_id: str, msg: dict):
@@ -237,64 +274,102 @@ class RpcRouter:
237
274
  async def _registry_register(self, caller_id: str, params: dict) -> dict:
238
275
  mid = params.get("module_id")
239
276
  if not mid:
240
- return {"ok": False, "error": "module_id required"}
277
+ raise ValueError("module_id required")
241
278
  # Permission: only Launcher or the module itself
242
279
  if caller_id != "launcher" and caller_id != mid:
243
- return {"ok": False, "error": f"Module '{caller_id}' cannot register as '{mid}'"}
280
+ raise PermissionError(f"Module '{caller_id}' cannot register as '{mid}'")
281
+
282
+ print(f"[kernel] registry.register called by {caller_id} for module {mid}")
283
+ print(f"[kernel] rpc_methods: {params.get('rpc_methods')}")
284
+
244
285
  result = self.registry.register_module(params)
245
- if result.get("ok"):
246
- self.event_hub.publish_internal("module.registered", {"module_id": mid})
247
-
248
- # When Launcher registers, Kernel publishes its own module.ready
249
- if mid == "launcher" and self.kernel_server:
250
- if not self.kernel_server._ready_published:
251
- self.kernel_server.publish_ready()
252
- self.kernel_server._ready_published = True
253
- print(f"[kernel] launcher registered → kernel module.ready published")
286
+ self.event_hub.publish_internal("module.registered", {"module_id": mid}, source=self.kernel_server.module_id)
287
+
288
+ # Only publish registry.updated if content actually changed
289
+ if result.get("changed", True):
290
+ from datetime import datetime, timezone
291
+ self.event_hub.publish_internal("registry.updated", {
292
+ "module_id": mid,
293
+ "timestamp": datetime.now(timezone.utc).isoformat(),
294
+ "action": "register",
295
+ }, source=self.kernel_server.module_id)
296
+ print(f"[kernel] registry.updated published for {mid}")
297
+ else:
298
+ print(f"[kernel] {mid} re-registered with no changes, skipping registry.updated")
299
+
300
+ # When Launcher registers, Kernel publishes its own module.ready
301
+ if mid == "launcher" and self.kernel_server:
302
+ self.kernel_server.publish_ready()
303
+ print(f"[kernel] launcher registered → kernel module.ready published")
304
+
254
305
  return result
255
306
 
256
307
  async def _registry_deregister(self, caller_id: str, params: dict) -> dict:
257
308
  mid = params.get("module_id")
258
309
  if not mid:
259
- return {"ok": False, "error": "module_id required"}
310
+ raise ValueError("module_id required")
260
311
  if caller_id != "launcher" and caller_id != mid:
261
- return {"ok": False, "error": f"Module '{caller_id}' cannot deregister '{mid}'"}
262
- result = self.registry.deregister_module(mid)
263
- if result.get("ok"):
264
- self.event_hub.publish_internal("module.unregistered", {"module_id": mid})
265
- return result
312
+ raise PermissionError(f"Module '{caller_id}' cannot deregister '{mid}'")
313
+
314
+ self.registry.deregister_module(mid)
315
+ self.event_hub.publish_internal("module.unregistered", {"module_id": mid}, source=self.kernel_server.module_id)
316
+
317
+ # Publish registry.updated event for cache invalidation
318
+ from datetime import datetime, timezone
319
+ self.event_hub.publish_internal("registry.updated", {
320
+ "module_id": mid,
321
+ "timestamp": datetime.now(timezone.utc).isoformat(),
322
+ "action": "deregister",
323
+ }, source=self.kernel_server.module_id)
324
+
325
+ return {}
266
326
 
267
327
  async def _registry_heartbeat(self, caller_id: str, params: dict) -> dict:
268
328
  mid = params.get("module_id")
269
329
  if not mid:
270
- return {"ok": False, "error": "module_id required"}
330
+ raise ValueError("module_id required")
271
331
  if caller_id != "launcher" and caller_id != mid:
272
- return {"ok": False, "error": f"Module '{caller_id}' cannot heartbeat for '{mid}'"}
332
+ raise PermissionError(f"Module '{caller_id}' cannot heartbeat for '{mid}'")
333
+
273
334
  return self.registry.heartbeat(mid)
274
335
 
275
336
  async def _registry_lookup(self, caller_id: str, params: dict) -> dict:
337
+ field = params.get("field")
338
+ module = params.get("module")
339
+ value = params.get("value")
340
+
341
+ print(f"[kernel] registry.lookup called by {caller_id}: field={field}, module={module}, value={value}")
342
+
276
343
  results = self.registry.lookup(
277
- field=params.get("field"),
278
- module=params.get("module"),
279
- value=params.get("value"),
344
+ field=field,
345
+ module=module,
346
+ value=value,
280
347
  )
281
- return {"ok": True, "results": results}
348
+
349
+ print(f"[kernel] registry.lookup results: {len(results)} matches")
350
+ for r in results:
351
+ print(f"[kernel] - {r['module']}.{r['field']} = {r['value']}")
352
+
353
+ return {
354
+ "results": results,
355
+ "last_update_time": self.registry.last_update_time
356
+ }
282
357
 
283
358
  async def _registry_get(self, caller_id: str, params: dict) -> dict:
284
359
  path = params.get("path", "")
285
360
  if not path:
286
- return {"ok": False, "error": "path required"}
361
+ raise ValueError("path required")
287
362
  val, found = self.registry.get_by_path(path)
288
363
  if not found:
289
- return {"ok": False, "error": f"Path not found: {path}"}
290
- return {"ok": True, "value": val}
364
+ raise KeyError(f"Path not found: {path}")
365
+ return {"value": val}
291
366
 
292
367
  async def _registry_verify(self, caller_id: str, params: dict) -> dict:
293
368
  token = params.get("token", "")
294
369
  module_id = self.registry.verify_token(token)
295
- if module_id:
296
- return {"ok": True, "module_id": module_id}
297
- return {"ok": False}
370
+ if not module_id:
371
+ raise PermissionError("Invalid token")
372
+ return {"module_id": module_id}
298
373
 
299
374
  # ── Builtin handlers: event.* ──
300
375
 
@@ -303,21 +378,26 @@ class RpcRouter:
303
378
  event_type = params.get("event", "")
304
379
  data = params.get("data")
305
380
  echo = params.get("echo", False)
381
+
382
+ # When a module publishes module.ready, update its status in registry
383
+ if event_type == "module.ready":
384
+ mid = (data or {}).get("module_id", caller_id)
385
+ self.registry.set_ready(mid)
386
+
306
387
  return self.event_hub.publish_event(caller_id, event_id, event_type, data, echo)
307
388
 
308
389
  async def _event_subscribe(self, caller_id: str, params: dict) -> dict:
309
390
  events = params.get("events", [])
310
391
  if not isinstance(events, list) or not events:
311
- return {"ok": False, "error": "events must be a non-empty list"}
392
+ raise ValueError("events must be a non-empty list")
312
393
  self.event_hub.handle_subscribe(caller_id, events)
313
- return {"ok": True}
394
+ return {}
314
395
 
315
396
  async def _event_unsubscribe(self, caller_id: str, params: dict) -> dict:
316
397
  events = params.get("events", [])
317
398
  if not isinstance(events, list) or not events:
318
- return {"ok": False, "error": "events must be a non-empty list"}
319
- self.event_hub.handle_unsubscribe(caller_id, events)
320
- return {"ok": True}
399
+ raise ValueError("events must be a non-empty list")
400
+ return self.event_hub.handle_unsubscribe(caller_id, events)
321
401
 
322
402
  # ── Builtin handlers: kernel.* ──
323
403
 
@@ -325,7 +405,16 @@ class RpcRouter:
325
405
  return {"pong": True, "timestamp": datetime.now(timezone.utc).isoformat()}
326
406
 
327
407
  async def _kernel_stats(self, caller_id: str, params: dict) -> dict:
328
- return self.event_hub.get_stats()
408
+ event_stats = self.event_hub.get_stats()
409
+ return {
410
+ **event_stats,
411
+ "rpc": {
412
+ "total": self._rpc_total,
413
+ "builtin": self._rpc_builtin,
414
+ "forwarded": self._rpc_forwarded,
415
+ "errors": self._rpc_errors
416
+ }
417
+ }
329
418
 
330
419
  async def _kernel_health(self, caller_id: str, params: dict) -> dict:
331
420
  eh_health = self.event_hub.get_health()
@@ -334,7 +423,7 @@ class RpcRouter:
334
423
  "module_count": len(self.registry.modules),
335
424
  "online_count": sum(
336
425
  1 for m in self.registry.modules.values()
337
- if m.get("status") == "online"
426
+ if m.get("status") in ("registered", "ready")
338
427
  ),
339
428
  "event_stats": eh_health.get("details", {}),
340
429
  }
@@ -346,15 +435,15 @@ class RpcRouter:
346
435
  params: {"modules": ["mod1", "mod2", ...]}
347
436
 
348
437
  Returns:
349
- {"ok": True, "tokens": {"mod1": "token1", "mod2": "token2", ...}}
438
+ {"tokens": {"mod1": "token1", "mod2": "token2", ...}}
350
439
  """
351
440
  # Only Launcher may request token generation
352
441
  if caller_id != "launcher":
353
- return {"ok": False, "error": "Only Launcher may generate tokens"}
442
+ raise PermissionError("Only Launcher may generate tokens")
354
443
 
355
444
  modules = params.get("modules", [])
356
445
  if not isinstance(modules, list):
357
- return {"ok": False, "error": "modules must be a list"}
446
+ raise ValueError("modules must be a list")
358
447
 
359
448
  import secrets
360
449
  tokens = {}
@@ -364,25 +453,13 @@ class RpcRouter:
364
453
  # Register tokens in registry
365
454
  self.registry.register_tokens(tokens)
366
455
 
367
- return {"ok": True, "tokens": tokens}
456
+ return {"tokens": tokens}
368
457
 
369
458
  async def _kernel_register_tokens(self, caller_id: str, params: dict) -> dict:
370
459
  # Only Launcher may register tokens
371
460
  if caller_id != "launcher":
372
- return {"ok": False, "error": "Only Launcher may register tokens"}
461
+ raise PermissionError("Only Launcher may register tokens")
373
462
  self.registry.register_tokens(params)
374
- return {"ok": True}
375
-
376
- async def _kernel_shutdown(self, caller_id: str, params: dict) -> dict:
377
- """Shutdown Kernel. Only Launcher may call this."""
378
- if caller_id != "launcher":
379
- return {"ok": False, "error": "Only Launcher may shutdown Kernel"}
380
-
381
- print("[kernel] Received shutdown request from Launcher")
382
-
383
- # Schedule shutdown (don't block RPC response)
384
- if self.kernel_server:
385
- asyncio.create_task(self.kernel_server.shutdown())
463
+ return {}
386
464
 
387
- return {"ok": True}
388
465