@agentunion/kite 1.3.0 → 1.3.2

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>AI Phone Agent</title>
7
- <link rel="stylesheet" href="css/style.css?v=35">
7
+ <link rel="stylesheet" href="css/style.css?v=36">
8
8
  </head>
9
9
  <body>
10
10
 
@@ -52,6 +52,10 @@
52
52
  <span class="nav-label">蓝牙</span>
53
53
  <span class="nav-sublabel">Bluetooth</span>
54
54
  </li>
55
+ <li class="nav-item" data-page="modules">
56
+ <span class="nav-label">模块</span>
57
+ <span class="nav-sublabel">Modules</span>
58
+ </li>
55
59
  <li class="nav-item" data-page="voicechat">
56
60
  <span class="nav-label">语音测试</span>
57
61
  <span class="nav-sublabel">Voice Chat</span>
@@ -960,6 +964,105 @@
960
964
  </div>
961
965
  </section>
962
966
 
967
+ <!-- ==================== Voice Chat ==================== -->
968
+
969
+ <!-- ==================== Modules ==================== -->
970
+ <section id="page-modules" class="page">
971
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
972
+ <h2 class="page-title" style="margin-bottom:0;">模块</h2>
973
+ <span id="module-save-status" class="config-save-status"></span>
974
+ </div>
975
+
976
+ <!-- Module Cards Grid -->
977
+ <div id="modules-grid" class="modules-grid">
978
+ <p class="text-muted" style="padding:40px;text-align:center;">加载中...</p>
979
+ </div>
980
+
981
+ <!-- Module Detail Panel (hidden by default) -->
982
+ <div id="module-detail" class="hidden">
983
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
984
+ <button class="btn btn-secondary btn-sm" id="btn-module-back">&larr; 返回</button>
985
+ <h3 id="module-detail-name" style="font-size:18px;font-weight:600;color:var(--gray-900);margin:0;"></h3>
986
+ <span id="module-detail-type-badge" class="module-type-badge"></span>
987
+ </div>
988
+
989
+ <!-- Metadata Form -->
990
+ <div class="form-section" id="module-meta-form">
991
+ <h4 class="form-title">元数据</h4>
992
+
993
+ <!-- Read-only fields -->
994
+ <div class="form-row" style="margin-bottom:14px;">
995
+ <div class="form-group">
996
+ <label>Name</label>
997
+ <input type="text" id="mod-meta-name" disabled style="background:var(--gray-100);cursor:not-allowed;">
998
+ </div>
999
+ <div class="form-group">
1000
+ <label>Type</label>
1001
+ <input type="text" id="mod-meta-type" disabled style="background:var(--gray-100);cursor:not-allowed;">
1002
+ </div>
1003
+ </div>
1004
+ <div class="form-row" style="margin-bottom:14px;">
1005
+ <div class="form-group">
1006
+ <label>Runtime</label>
1007
+ <input type="text" id="mod-meta-runtime" disabled style="background:var(--gray-100);cursor:not-allowed;">
1008
+ </div>
1009
+ <div class="form-group">
1010
+ <label>Entry</label>
1011
+ <input type="text" id="mod-meta-entry" disabled style="background:var(--gray-100);cursor:not-allowed;">
1012
+ </div>
1013
+ </div>
1014
+
1015
+ <hr style="border:none;border-top:1px solid var(--gray-200);margin:16px 0;">
1016
+
1017
+ <!-- Editable fields -->
1018
+ <div class="form-row">
1019
+ <div class="form-group">
1020
+ <label>Display Name</label>
1021
+ <input type="text" id="mod-meta-display-name" data-field="display_name">
1022
+ </div>
1023
+ <div class="form-group">
1024
+ <label>Version</label>
1025
+ <input type="text" id="mod-meta-version" data-field="version">
1026
+ </div>
1027
+ </div>
1028
+ <div class="form-row">
1029
+ <div class="form-group">
1030
+ <label>State</label>
1031
+ <select id="mod-meta-state" data-field="state">
1032
+ <option value="enabled">enabled</option>
1033
+ <option value="manual">manual</option>
1034
+ <option value="disabled">disabled</option>
1035
+ </select>
1036
+ </div>
1037
+ <div class="form-group">
1038
+ <label>Preferred Port</label>
1039
+ <input type="number" id="mod-meta-port" data-field="preferred_port" placeholder="(无)">
1040
+ </div>
1041
+ </div>
1042
+ <div class="form-row">
1043
+ <div class="form-group">
1044
+ <label>Advertise IP</label>
1045
+ <input type="text" id="mod-meta-ip" data-field="advertise_ip" placeholder="0.0.0.0">
1046
+ </div>
1047
+ <div class="form-group">
1048
+ <label>Monitor</label>
1049
+ <select id="mod-meta-monitor" data-field="monitor">
1050
+ <option value="">(未设置)</option>
1051
+ <option value="true">true</option>
1052
+ <option value="false">false</option>
1053
+ </select>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+
1058
+ <!-- Config Tree (only shown if module has config.yaml) -->
1059
+ <div class="form-section hidden" id="module-config-section">
1060
+ <h4 class="form-title">config.yaml</h4>
1061
+ <div id="module-config-tree"></div>
1062
+ </div>
1063
+ </div>
1064
+ </section>
1065
+
963
1066
  <!-- ==================== Voice Chat ==================== -->
964
1067
  <section id="page-voicechat" class="page">
965
1068
  <div class="vc-layout">
@@ -1440,6 +1543,6 @@
1440
1543
  <!-- ===== Toast / Notification Area ===== -->
1441
1544
  <div id="toast-container"></div>
1442
1545
 
1443
- <script src="js/app.js?v=51"></script>
1546
+ <script src="js/app.js?v=52"></script>
1444
1547
  </body>
1445
1548
  </html>
@@ -97,7 +97,7 @@ function showToast(message, type = 'info') {
97
97
  // ============================================================
98
98
  // Navigation / Router
99
99
  // ============================================================
100
- const pages = ['dashboard', 'calls', 'sms', 'contacts', 'config', 'bluetooth', 'voicechat', 'devlog'];
100
+ const pages = ['dashboard', 'calls', 'sms', 'contacts', 'config', 'bluetooth', 'modules', 'voicechat', 'devlog'];
101
101
  let currentPage = '';
102
102
 
103
103
  function navigate(page) {
@@ -128,6 +128,7 @@ function navigate(page) {
128
128
  case 'contacts': loadContacts(); break;
129
129
  case 'config': loadConfig(); break;
130
130
  case 'bluetooth': loadBluetooth(); break;
131
+ case 'modules': loadModules(); break;
131
132
  case 'voicechat': loadVoiceChatConfig(); break;
132
133
  case 'devlog': loadDevLog(); break;
133
134
  }
@@ -4202,6 +4203,282 @@ function closeDevLogArchiveModal() {
4202
4203
  document.getElementById('devlog-archive-modal')?.classList.add('hidden');
4203
4204
  }
4204
4205
 
4206
+ // ============================================================
4207
+ // Modules Page
4208
+ // ============================================================
4209
+ let _moduleSaveTimer = null;
4210
+ let _currentModuleName = '';
4211
+
4212
+ async function loadModules() {
4213
+ // Reset to grid view
4214
+ document.getElementById('modules-grid')?.classList.remove('hidden');
4215
+ document.getElementById('module-detail')?.classList.add('hidden');
4216
+
4217
+ try {
4218
+ const modules = await API.get('/api/modules');
4219
+ renderModulesGrid(modules);
4220
+ } catch (err) {
4221
+ const grid = document.getElementById('modules-grid');
4222
+ if (grid) grid.innerHTML = `<p class="text-muted" style="padding:40px;text-align:center;">加载失败: ${escapeHtml(err.message)}</p>`;
4223
+ }
4224
+ }
4225
+
4226
+ function renderModulesGrid(modules) {
4227
+ const grid = document.getElementById('modules-grid');
4228
+ if (!grid) return;
4229
+
4230
+ if (!modules.length) {
4231
+ grid.innerHTML = '<p class="text-muted" style="padding:40px;text-align:center;">未发现模块</p>';
4232
+ return;
4233
+ }
4234
+
4235
+ grid.innerHTML = modules.map(m => {
4236
+ const stateClass = m.state === 'enabled' ? 'enabled' : m.state === 'manual' ? 'manual' : 'disabled';
4237
+ const typeClass = m.type || 'unknown';
4238
+ const displayName = m.display_name || m.name;
4239
+ const version = m.version ? `<span class="module-version">v${escapeHtml(String(m.version))}</span>` : '';
4240
+ const configBadge = m.has_config ? '<span class="badge badge-info" style="font-size:10px;">config</span>' : '';
4241
+ return `<div class="module-card" data-name="${escapeHtml(m.name)}" onclick="openModuleDetail('${escapeHtml(m.name)}')">
4242
+ <div class="module-card-header">
4243
+ <span class="module-state-dot ${stateClass}" title="${escapeHtml(m.state || 'enabled')}"></span>
4244
+ <span class="module-type-badge type-${escapeHtml(typeClass)}">${escapeHtml(m.type || '?')}</span>
4245
+ ${version}
4246
+ </div>
4247
+ <div class="module-card-name">${escapeHtml(m.name)}</div>
4248
+ <div class="module-card-display-name">${escapeHtml(displayName)}</div>
4249
+ <div class="module-card-footer">
4250
+ ${m.preferred_port ? '<span style="font-size:11px;color:var(--gray-400);">:' + m.preferred_port + '</span>' : ''}
4251
+ ${configBadge}
4252
+ </div>
4253
+ </div>`;
4254
+ }).join('');
4255
+ }
4256
+
4257
+ async function openModuleDetail(name) {
4258
+ _currentModuleName = name;
4259
+
4260
+ // Switch view
4261
+ document.getElementById('modules-grid')?.classList.add('hidden');
4262
+ document.getElementById('module-detail')?.classList.remove('hidden');
4263
+
4264
+ try {
4265
+ const mod = await API.get(`/api/modules/${encodeURIComponent(name)}`);
4266
+
4267
+ // Header
4268
+ document.getElementById('module-detail-name').textContent = mod.display_name || mod.name;
4269
+ const badge = document.getElementById('module-detail-type-badge');
4270
+ if (badge) {
4271
+ badge.textContent = mod.type || '?';
4272
+ badge.className = `module-type-badge type-${mod.type || 'unknown'}`;
4273
+ }
4274
+
4275
+ // Read-only fields
4276
+ _setVal('mod-meta-name', mod.name || '');
4277
+ _setVal('mod-meta-type', mod.type || '');
4278
+ _setVal('mod-meta-runtime', mod.runtime || '');
4279
+ _setVal('mod-meta-entry', mod.entry || '');
4280
+
4281
+ // Editable fields
4282
+ _setVal('mod-meta-display-name', mod.display_name || '');
4283
+ _setVal('mod-meta-version', mod.version || '');
4284
+ _setVal('mod-meta-state', mod.state || 'enabled');
4285
+ _setVal('mod-meta-port', mod.preferred_port != null ? mod.preferred_port : '');
4286
+ _setVal('mod-meta-ip', mod.advertise_ip || '');
4287
+ const monitorEl = document.getElementById('mod-meta-monitor');
4288
+ if (monitorEl) monitorEl.value = mod.monitor != null ? String(mod.monitor) : '';
4289
+
4290
+ // Config section
4291
+ const configSection = document.getElementById('module-config-section');
4292
+ const configTree = document.getElementById('module-config-tree');
4293
+ if (mod.has_config && mod.config) {
4294
+ configSection?.classList.remove('hidden');
4295
+ if (configTree) {
4296
+ configTree.innerHTML = '';
4297
+ renderConfigTree(mod.config, configTree, '');
4298
+ }
4299
+ } else {
4300
+ configSection?.classList.add('hidden');
4301
+ }
4302
+ } catch (err) {
4303
+ showToast('加载模块详情失败: ' + err.message, 'error');
4304
+ }
4305
+ }
4306
+
4307
+ function renderConfigTree(obj, container, pathPrefix) {
4308
+ if (!obj || typeof obj !== 'object') return;
4309
+
4310
+ // Secret field patterns
4311
+ const secretKeys = /(?:password|secret|token|api_key|access_key|private_key)/i;
4312
+
4313
+ Object.entries(obj).forEach(([key, val]) => {
4314
+ const fullPath = pathPrefix ? `${pathPrefix}.${key}` : key;
4315
+
4316
+ if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
4317
+ // Nested object → collapsible group
4318
+ const group = document.createElement('div');
4319
+ group.className = 'config-tree-group';
4320
+
4321
+ const header = document.createElement('div');
4322
+ header.className = 'config-tree-key';
4323
+ header.textContent = key;
4324
+ header.addEventListener('click', () => {
4325
+ group.classList.toggle('collapsed');
4326
+ });
4327
+
4328
+ const nested = document.createElement('div');
4329
+ nested.className = 'config-tree-nested';
4330
+
4331
+ group.appendChild(header);
4332
+ group.appendChild(nested);
4333
+ container.appendChild(group);
4334
+
4335
+ renderConfigTree(val, nested, fullPath);
4336
+ } else {
4337
+ // Leaf value → form input
4338
+ const row = document.createElement('div');
4339
+ row.className = 'config-tree-leaf';
4340
+
4341
+ const label = document.createElement('label');
4342
+ label.className = 'config-tree-label';
4343
+ label.textContent = key;
4344
+
4345
+ let input;
4346
+ if (typeof val === 'boolean') {
4347
+ input = document.createElement('input');
4348
+ input.type = 'checkbox';
4349
+ input.checked = val;
4350
+ input.className = 'config-tree-checkbox';
4351
+ } else if (typeof val === 'number') {
4352
+ input = document.createElement('input');
4353
+ input.type = 'number';
4354
+ input.value = val;
4355
+ input.step = 'any';
4356
+ input.className = 'config-tree-input';
4357
+ } else if (Array.isArray(val)) {
4358
+ input = document.createElement('input');
4359
+ input.type = 'text';
4360
+ input.value = val.join(', ');
4361
+ input.className = 'config-tree-input';
4362
+ input.placeholder = 'comma separated';
4363
+ } else {
4364
+ input = document.createElement('input');
4365
+ input.type = secretKeys.test(key) ? 'password' : 'text';
4366
+ input.value = val != null ? String(val) : '';
4367
+ input.className = 'config-tree-input';
4368
+ }
4369
+
4370
+ input.dataset.path = fullPath;
4371
+ input.dataset.origType = Array.isArray(val) ? 'array' : typeof val;
4372
+
4373
+ // Debounced save on input/change
4374
+ const saveEvent = (input.type === 'checkbox' || input.tagName === 'SELECT') ? 'change' : 'input';
4375
+ input.addEventListener(saveEvent, _debouncedSaveModule);
4376
+
4377
+ row.appendChild(label);
4378
+ row.appendChild(input);
4379
+ container.appendChild(row);
4380
+ }
4381
+ });
4382
+ }
4383
+
4384
+ function _buildModuleConfigObject() {
4385
+ const tree = document.getElementById('module-config-tree');
4386
+ if (!tree) return null;
4387
+
4388
+ const obj = {};
4389
+ tree.querySelectorAll('[data-path]').forEach(input => {
4390
+ const path = input.dataset.path;
4391
+ const origType = input.dataset.origType;
4392
+ let val;
4393
+
4394
+ if (input.type === 'checkbox') {
4395
+ val = input.checked;
4396
+ } else if (origType === 'number') {
4397
+ val = input.value === '' ? 0 : Number(input.value);
4398
+ } else if (origType === 'array') {
4399
+ val = input.value.split(',').map(s => s.trim()).filter(Boolean);
4400
+ } else {
4401
+ val = input.value;
4402
+ }
4403
+
4404
+ // Build nested object from dot path
4405
+ const parts = path.split('.');
4406
+ let target = obj;
4407
+ for (let i = 0; i < parts.length - 1; i++) {
4408
+ if (!(parts[i] in target)) target[parts[i]] = {};
4409
+ target = target[parts[i]];
4410
+ }
4411
+ target[parts[parts.length - 1]] = val;
4412
+ });
4413
+
4414
+ return obj;
4415
+ }
4416
+
4417
+ function _debouncedSaveModule() {
4418
+ clearTimeout(_moduleSaveTimer);
4419
+ _moduleSaveTimer = setTimeout(async () => {
4420
+ _showModuleSaveStatus('saving');
4421
+ try {
4422
+ // Collect metadata
4423
+ const meta = {};
4424
+ document.querySelectorAll('#module-meta-form [data-field]').forEach(el => {
4425
+ const field = el.dataset.field;
4426
+ let val;
4427
+ if (field === 'preferred_port') {
4428
+ val = el.value === '' ? null : parseInt(el.value);
4429
+ } else if (field === 'monitor') {
4430
+ val = el.value === '' ? null : el.value === 'true';
4431
+ } else {
4432
+ val = el.value || null;
4433
+ }
4434
+ if (val !== null) meta[field] = val;
4435
+ });
4436
+
4437
+ // Save metadata
4438
+ const metaPromise = Object.keys(meta).length > 0
4439
+ ? API.put(`/api/modules/${encodeURIComponent(_currentModuleName)}/metadata`, meta)
4440
+ : Promise.resolve();
4441
+
4442
+ // Save config if visible
4443
+ const configSection = document.getElementById('module-config-section');
4444
+ const configObj = _buildModuleConfigObject();
4445
+ const configPromise = configSection && !configSection.classList.contains('hidden') && configObj
4446
+ ? API.put(`/api/modules/${encodeURIComponent(_currentModuleName)}/config`, configObj)
4447
+ : Promise.resolve();
4448
+
4449
+ await Promise.all([metaPromise, configPromise]);
4450
+ _showModuleSaveStatus('saved');
4451
+ } catch (err) {
4452
+ _showModuleSaveStatus('error');
4453
+ showToast('保存失败: ' + err.message, 'error');
4454
+ }
4455
+ }, 800);
4456
+ }
4457
+
4458
+ function _showModuleSaveStatus(status) {
4459
+ const el = document.getElementById('module-save-status');
4460
+ if (!el) return;
4461
+ el.classList.remove('saving', 'saved', 'error', 'fade-out');
4462
+ if (status === 'saving') {
4463
+ el.textContent = '保存中...';
4464
+ el.classList.add('saving');
4465
+ } else if (status === 'saved') {
4466
+ el.textContent = '已保存';
4467
+ el.classList.add('saved');
4468
+ setTimeout(() => {
4469
+ el.classList.add('fade-out');
4470
+ setTimeout(() => { el.textContent = ''; el.classList.remove('saved', 'fade-out'); }, 300);
4471
+ }, 2000);
4472
+ } else if (status === 'error') {
4473
+ el.textContent = '保存失败';
4474
+ el.classList.add('error');
4475
+ setTimeout(() => {
4476
+ el.classList.add('fade-out');
4477
+ setTimeout(() => { el.textContent = ''; el.classList.remove('error', 'fade-out'); }, 300);
4478
+ }, 3000);
4479
+ }
4480
+ }
4481
+
4205
4482
  // ============================================================
4206
4483
  // Initialization
4207
4484
  // ============================================================
@@ -4663,6 +4940,16 @@ document.addEventListener('DOMContentLoaded', () => {
4663
4940
  if (dateEl) loadDevLogArchiveDetail(dateEl.dataset.date);
4664
4941
  });
4665
4942
 
4943
+ // Modules page
4944
+ const btnModuleBack = document.getElementById('btn-module-back');
4945
+ if (btnModuleBack) btnModuleBack.addEventListener('click', loadModules);
4946
+
4947
+ // Module metadata form auto-save
4948
+ document.querySelectorAll('#module-meta-form [data-field]').forEach(el => {
4949
+ const event = (el.tagName === 'SELECT') ? 'change' : 'input';
4950
+ el.addEventListener(event, _debouncedSaveModule);
4951
+ });
4952
+
4666
4953
  // Navigate to last active page (or dashboard)
4667
4954
  navigate(localStorage.getItem('activePage') || 'dashboard');
4668
4955
 
@@ -40,6 +40,9 @@ def _loads(raw: str):
40
40
 
41
41
  QUEUE_MAXSIZE = 10000
42
42
 
43
+ # System events that are auto-broadcast to ALL connected modules (no subscription needed)
44
+ SYSTEM_EVENTS = {"module.offline", "module.ready", "module.shutdown"}
45
+
43
46
 
44
47
  class EventHub:
45
48
 
@@ -193,10 +196,27 @@ class EventHub:
193
196
  # ── Routing ──
194
197
 
195
198
  def _route_event(self, sender_id: str, msg: dict, event_type: str, echo: bool):
196
- """Enqueue event to all matching subscribers' delivery queues."""
199
+ """Enqueue event to all matching subscribers' delivery queues.
200
+ System events (module.offline, module.ready, module.shutdown) are auto-broadcast
201
+ to ALL connected modules, regardless of subscription."""
197
202
  e_parts = tuple(event_type.split("."))
198
203
  raw = None # lazy serialization
199
204
 
205
+ # System events → broadcast to all connected modules
206
+ if event_type in SYSTEM_EVENTS:
207
+ for mid, queue in self._queues.items():
208
+ if mid == sender_id and not echo:
209
+ continue
210
+ if raw is None:
211
+ raw = _dumps(msg)
212
+ try:
213
+ queue.put_nowait(raw)
214
+ except asyncio.QueueFull:
215
+ pass
216
+ self._cnt_queued += 1
217
+ return
218
+
219
+ # Normal events → subscription-based routing
200
220
  for mid, patterns in self.subscriptions.items():
201
221
  if mid == sender_id and not echo:
202
222
  continue
@@ -209,8 +229,6 @@ class EventHub:
209
229
  try:
210
230
  queue.put_nowait(raw)
211
231
  except asyncio.QueueFull:
212
- # Best-effort: drop if queue is full and we can't await
213
- # (we're not in an async context in _route_event)
214
232
  pass
215
233
  self._cnt_queued += 1
216
234
  break
@@ -77,7 +77,7 @@ class RegistryStore:
77
77
 
78
78
  # Strip action field — it's a request verb, not part of the registration payload
79
79
  record = {k: v for k, v in data.items() if k != "action"}
80
- record["status"] = "online"
80
+ record["status"] = "registered" # State machine: connected → registered (via register RPC)
81
81
  record["registered_at"] = time.time()
82
82
  self.modules[mid] = record
83
83
  self.heartbeats[mid] = time.time()
@@ -94,21 +94,38 @@ class RegistryStore:
94
94
  if module_id not in self.modules:
95
95
  return {"ok": False, "error": "module not registered"}
96
96
  self.heartbeats[module_id] = time.time()
97
- self.modules[module_id]["status"] = "online"
97
+ # Don't change status heartbeat just keeps alive, doesn't upgrade state
98
98
  return {"ok": True}
99
99
 
100
+ def set_connected(self, module_id: str):
101
+ """Mark a module as connected (WS established, not yet registered).
102
+ Only upgrades from offline; doesn't downgrade from registered/ready."""
103
+ if module_id in self.modules:
104
+ if self.modules[module_id].get("status") == "offline":
105
+ self.modules[module_id]["status"] = "connected"
106
+
107
+ def set_ready(self, module_id: str):
108
+ """Mark a module as ready (received module.ready event)."""
109
+ if module_id in self.modules:
110
+ self.modules[module_id]["status"] = "ready"
111
+
100
112
  def set_offline(self, module_id: str):
101
- """Mark a module as offline (called on WebSocket disconnect)."""
113
+ """Mark a module as offline (called after debounce on WebSocket disconnect)."""
102
114
  if module_id in self.modules:
103
115
  self.modules[module_id]["status"] = "offline"
104
116
 
117
+ def is_ready(self, module_id: str) -> bool:
118
+ """Check if a module is in ready state."""
119
+ mod = self.modules.get(module_id)
120
+ return mod is not None and mod.get("status") == "ready"
121
+
105
122
  def check_ttl(self) -> list[str]:
106
123
  """Mark modules as offline if heartbeat expired. Returns list of newly-offline module_ids."""
107
124
  now = time.time()
108
125
  expired = []
109
126
  for mid, last in list(self.heartbeats.items()):
110
127
  if mid in self.modules and now - last > self.ttl:
111
- if self.modules[mid].get("status") != "offline":
128
+ if self.modules[mid].get("status") not in ("offline",):
112
129
  self.modules[mid]["status"] = "offline"
113
130
  expired.append(mid)
114
131
  return expired
@@ -164,7 +181,7 @@ class RegistryStore:
164
181
  """
165
182
  results = []
166
183
  for mid, data in self.modules.items():
167
- if data.get("status") != "online":
184
+ if data.get("status") not in ("registered", "ready"):
168
185
  continue
169
186
  if module and not fnmatch.fnmatch(mid, module):
170
187
  continue
@@ -124,6 +124,12 @@ class RpcRouter:
124
124
  if dot_idx > 0:
125
125
  target = method[:dot_idx]
126
126
  if target in self.connections:
127
+ # Check if target module is ready (state machine protection)
128
+ if not self.registry.is_ready(target):
129
+ await ws.send_text(_error_msg(
130
+ msg_id, MODULE_OFFLINE,
131
+ f"Module not ready: {target} (status: {self.registry.modules.get(target, {}).get('status', 'unknown')})"))
132
+ return
127
133
  await self._forward(caller_id, ws, msg_id, target, method, params)
128
134
  return
129
135
  # Target not connected — check if registered but offline
@@ -247,10 +253,8 @@ class RpcRouter:
247
253
 
248
254
  # When Launcher registers, Kernel publishes its own module.ready
249
255
  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")
256
+ self.kernel_server.publish_ready()
257
+ print(f"[kernel] launcher registered → kernel module.ready published")
254
258
  return result
255
259
 
256
260
  async def _registry_deregister(self, caller_id: str, params: dict) -> dict:
@@ -303,6 +307,12 @@ class RpcRouter:
303
307
  event_type = params.get("event", "")
304
308
  data = params.get("data")
305
309
  echo = params.get("echo", False)
310
+
311
+ # When a module publishes module.ready, update its status in registry
312
+ if event_type == "module.ready":
313
+ mid = (data or {}).get("module_id", caller_id)
314
+ self.registry.set_ready(mid)
315
+
306
316
  return self.event_hub.publish_event(caller_id, event_id, event_type, data, echo)
307
317
 
308
318
  async def _event_subscribe(self, caller_id: str, params: dict) -> dict:
@@ -334,7 +344,7 @@ class RpcRouter:
334
344
  "module_count": len(self.registry.modules),
335
345
  "online_count": sum(
336
346
  1 for m in self.registry.modules.values()
337
- if m.get("status") == "online"
347
+ if m.get("status") in ("registered", "ready")
338
348
  ),
339
349
  "event_stats": eh_health.get("details", {}),
340
350
  }