stackchan-mcp 0.9.1__tar.gz → 0.11.0__tar.gz

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. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/PKG-INFO +5 -1
  2. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/pyproject.toml +13 -2
  3. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/mdns_advertiser.py +179 -9
  4. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/ownership.py +142 -15
  5. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stdio_server.py +246 -9
  6. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/__init__.py +7 -0
  7. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/base.py +5 -0
  8. stackchan_mcp-0.11.0/stackchan_mcp/tts/emoji_expression.py +80 -0
  9. stackchan_mcp-0.11.0/stackchan_mcp/tts/irodori.py +358 -0
  10. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/orchestrator.py +187 -13
  11. stackchan_mcp-0.11.0/tests/test_emoji_expression.py +44 -0
  12. stackchan_mcp-0.11.0/tests/test_irodori.py +438 -0
  13. stackchan_mcp-0.11.0/tests/test_mdns_advertiser.py +913 -0
  14. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_orchestrator.py +303 -21
  15. stackchan_mcp-0.11.0/tests/test_ownership.py +434 -0
  16. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stdio_server.py +293 -0
  17. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_tts_framework.py +77 -0
  18. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/uv.lock +50 -2
  19. stackchan_mcp-0.9.1/tests/test_mdns_advertiser.py +0 -379
  20. stackchan_mcp-0.9.1/tests/test_ownership.py +0 -92
  21. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/.env.example +0 -0
  22. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/.gitignore +0 -0
  23. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/AGENTS.md +0 -0
  24. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/LICENSE +0 -0
  25. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/LICENSE-THIRD-PARTY +0 -0
  26. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/README.md +0 -0
  27. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/__init__.py +0 -0
  28. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/__main__.py +0 -0
  29. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  30. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_input_hook.py +0 -0
  31. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_stream.py +0 -0
  32. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/capture_server.py +0 -0
  33. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/cli.py +0 -0
  34. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/esp32_client.py +0 -0
  35. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/event_log.py +0 -0
  36. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/gateway.py +0 -0
  37. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/__init__.py +0 -0
  38. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/audio.py +0 -0
  39. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/camera.py +0 -0
  40. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/robot.py +0 -0
  41. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/http_server.py +0 -0
  42. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/mcp_router.py +0 -0
  43. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/notify.example.yml +0 -0
  44. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/notify_config.py +0 -0
  45. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/protocol.py +0 -0
  46. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/queue.py +0 -0
  47. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/server.py +0 -0
  48. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/__init__.py +0 -0
  49. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  50. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/base.py +0 -0
  51. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  52. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  53. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  54. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tools.py +0 -0
  55. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  56. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/voicevox.py +0 -0
  57. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/_audio_fixtures.py +0 -0
  58. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/conftest.py +0 -0
  59. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_audio_input_hook.py +0 -0
  60. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_audio_stream.py +0 -0
  61. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_audio_utils.py +0 -0
  62. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_capture_server.py +0 -0
  63. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_cli.py +0 -0
  64. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_esp32_client.py +0 -0
  65. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_event_dispatch.py +0 -0
  66. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_event_log.py +0 -0
  67. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_gateway.py +0 -0
  68. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_http_server.py +0 -0
  69. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_mcp_router.py +0 -0
  70. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_notify_config.py +0 -0
  71. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_protocol.py +0 -0
  72. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_send_pcm_audio.py +0 -0
  73. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_send_pcm_stream.py +0 -0
  74. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stackchan_event.py +0 -0
  75. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stt_audio_utils.py +0 -0
  76. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stt_framework.py +0 -0
  77. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_stt_orchestrator.py +0 -0
  78. {stackchan_mcp-0.9.1 → stackchan_mcp-0.11.0}/tests/test_voicevox.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackchan-mcp
3
- Version: 0.9.1
3
+ Version: 0.11.0
4
4
  Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
5
5
  Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
6
6
  Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
@@ -41,6 +41,10 @@ Requires-Dist: opuslib>=3; extra == 'stt-openai'
41
41
  Provides-Extra: tts
42
42
  Requires-Dist: httpx>=0.27; extra == 'tts'
43
43
  Requires-Dist: opuslib>=3; extra == 'tts'
44
+ Provides-Extra: tts-irodori
45
+ Requires-Dist: httpx>=0.27; extra == 'tts-irodori'
46
+ Requires-Dist: miniaudio>=1.59; extra == 'tts-irodori'
47
+ Requires-Dist: opuslib>=3; extra == 'tts-irodori'
44
48
  Provides-Extra: tts-voicevox
45
49
  Requires-Dist: httpx>=0.27; extra == 'tts-voicevox'
46
50
  Requires-Dist: opuslib>=3; extra == 'tts-voicevox'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.9.1"
3
+ version = "0.11.0"
4
4
  description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -51,7 +51,7 @@ dependencies = [
51
51
  [project.optional-dependencies]
52
52
  # Phase 4 TTS — see Issue #70.
53
53
  # Concrete engines (VOICEVOX, Irodori) consume these libraries:
54
- # * httpx — VOICEVOX HTTP engine client
54
+ # * httpx — VOICEVOX / Irodori HTTP engine client
55
55
  # * opuslib — Opus encoding for the device's audio decoder
56
56
  # `tts-voicevox` is a no-op alias provided so users can declare intent
57
57
  # explicitly; the VOICEVOX engine itself is an external HTTP process and
@@ -63,6 +63,17 @@ tts = [
63
63
  tts-voicevox = [
64
64
  "stackchan-mcp[tts]",
65
65
  ]
66
+ # Irodori is an external HTTP synthesis service that returns MP3 (see
67
+ # Issue #286). On top of the base `[tts]` extra (httpx + opuslib) it adds
68
+ # `miniaudio` to decode the MP3 response to PCM. miniaudio is a small,
69
+ # self-contained C extension (MIT) with prebuilt wheels for Windows,
70
+ # macOS (x86_64 + arm64), and Linux, so it installs without a system
71
+ # audio library — unlike a ffmpeg/libav binding — which keeps the
72
+ # Windows wheel path (and the CI windows-smoke job) simple.
73
+ tts-irodori = [
74
+ "stackchan-mcp[tts]",
75
+ "miniaudio>=1.59",
76
+ ]
66
77
 
67
78
  # Phase 4 STT — see Issue #91.
68
79
  # The base `stt` extra carries `opuslib` for decoding the device's
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import ipaddress
6
7
  import logging
7
8
  import socket
@@ -238,6 +239,26 @@ def _resolve_concrete_host_ipv4_addresses(host: str) -> list[str]:
238
239
  return _select_advertised_addresses([(address, None) for address in addresses])
239
240
 
240
241
 
242
+ def _resolve_addresses_for_host(host: str) -> list[str]:
243
+ """Resolve advertised addresses for ``host`` using the same logic as
244
+ :func:`build_advertisement`.
245
+
246
+ Wildcard hosts (``0.0.0.0`` / ``*`` / ``""``) enumerate every usable
247
+ non-loopback IPv4 address on the machine; concrete hosts only resolve
248
+ addresses for that specific host. The refresh loop calls this helper so
249
+ its comparison set matches what a fresh ``build_advertisement(...)``
250
+ would actually register — without this, a host started with a concrete
251
+ HOST in a multi-NIC / Tailscale environment would see every poll as an
252
+ "address change" against an unrelated extra interface and churn the
253
+ registration on every refresh interval.
254
+ """
255
+ return (
256
+ _enumerate_usable_ipv4_addresses()
257
+ if _is_wildcard_host(host)
258
+ else _resolve_concrete_host_ipv4_addresses(host)
259
+ )
260
+
261
+
241
262
  def build_advertisement(
242
263
  *,
243
264
  host: str,
@@ -253,11 +274,7 @@ def build_advertisement(
253
274
  return None
254
275
 
255
276
  normalized_path = path if path.startswith("/") else f"/{path}"
256
- addresses = (
257
- _enumerate_usable_ipv4_addresses()
258
- if _is_wildcard_host(host)
259
- else _resolve_concrete_host_ipv4_addresses(host)
260
- )
277
+ addresses = _resolve_addresses_for_host(host)
261
278
  if not addresses:
262
279
  logger.warning(
263
280
  "mDNS advertisement skipped: no usable non-loopback IPv4 address "
@@ -280,15 +297,57 @@ def build_advertisement(
280
297
  class MdnsAdvertiser:
281
298
  """Registers the gateway's WebSocket endpoint via mDNS/DNS-SD."""
282
299
 
283
- def __init__(self) -> None:
300
+ def __init__(self, *, refresh_interval: float = 30.0) -> None:
301
+ if refresh_interval < 10.0 or refresh_interval > 300.0:
302
+ raise ValueError("refresh_interval must be between 10.0 and 300.0 seconds")
303
+ self._lock: asyncio.Lock = asyncio.Lock()
284
304
  self._zeroconf: Any | None = None
285
305
  self._service_info: Any | None = None
306
+ self._refresh_task: asyncio.Task | None = None
307
+ self._last_advertised_addresses: tuple[str, ...] | None = None
308
+ self._refresh_interval: float = refresh_interval
309
+ self._start_args: dict[str, Any] | None = None
286
310
 
287
311
  async def start(self, *, host: str, port: int, path: str = "/") -> None:
312
+ task = self._refresh_task
313
+ if task is not None and task is not asyncio.current_task():
314
+ task.cancel()
315
+ try:
316
+ await asyncio.wait_for(task, timeout=1.0)
317
+ except (asyncio.CancelledError, TimeoutError):
318
+ pass
319
+
320
+ async with self._lock:
321
+ self._refresh_task = None
322
+ if self._zeroconf is not None:
323
+ await self._close_zeroconf_locked()
324
+ self._start_args = {"host": host, "port": port, "path": path}
325
+ try:
326
+ advertisement = await self._start_locked(host=host, port=port, path=path)
327
+ except Exception:
328
+ self._start_args = None
329
+ self._last_advertised_addresses = None
330
+ raise
331
+ if advertisement is None:
332
+ self._start_args = None
333
+ self._last_advertised_addresses = None
334
+ return
335
+ self._last_advertised_addresses = tuple(advertisement.parsed_addresses)
336
+ self._refresh_task = asyncio.create_task(self._refresh_loop())
337
+
338
+ async def _start_locked(
339
+ self, *, host: str, port: int, path: str = "/"
340
+ ) -> MdnsAdvertisement | None:
288
341
  advertisement = build_advertisement(host=host, port=port, path=path)
289
342
  if advertisement is None:
290
- return
343
+ return None
291
344
 
345
+ await self._register_advertisement_locked(advertisement)
346
+ return advertisement
347
+
348
+ async def _register_advertisement_locked(
349
+ self, advertisement: MdnsAdvertisement
350
+ ) -> None:
292
351
  AsyncZeroconf, ServiceInfo = _load_zeroconf_classes()
293
352
  # Constrain zeroconf to the IPv4 interfaces we actually advertise on.
294
353
  # The default ``InterfaceChoice.All`` makes zeroconf bind a socket on
@@ -309,7 +368,15 @@ class MdnsAdvertiser:
309
368
  )
310
369
  try:
311
370
  await zeroconf.async_register_service(info, allow_name_change=True)
312
- except Exception:
371
+ except BaseException:
372
+ # Catch BaseException (not just Exception) so that an
373
+ # ``asyncio.CancelledError`` arriving mid-registration —
374
+ # e.g. an external ``stop()`` or a double-``start()`` racing
375
+ # this registration — still closes the partially-constructed
376
+ # ``AsyncZeroconf``. Otherwise the bound multicast sockets
377
+ # and any partial registration would leak past the cancelled
378
+ # task. ``raise`` preserves the cancellation semantics for
379
+ # the surrounding ``async with self._lock`` caller.
313
380
  await zeroconf.async_close()
314
381
  raise
315
382
  self._zeroconf = zeroconf
@@ -326,13 +393,28 @@ class MdnsAdvertiser:
326
393
  advertisement.service_name,
327
394
  )
328
395
  logger.info(
329
- "mDNS advertising %s on port %d with addresses %s",
396
+ "mDNS advertising %s on port %d with addresses %s reason=register",
330
397
  registered_name,
331
398
  advertisement.port,
332
399
  ", ".join(advertisement.parsed_addresses),
333
400
  )
334
401
 
335
402
  async def stop(self) -> None:
403
+ task = self._refresh_task
404
+ if task is not None and task is not asyncio.current_task():
405
+ task.cancel()
406
+ try:
407
+ await asyncio.wait_for(task, timeout=1.0)
408
+ except (asyncio.CancelledError, TimeoutError):
409
+ pass
410
+
411
+ async with self._lock:
412
+ await self._close_zeroconf_locked()
413
+ self._refresh_task = None
414
+ self._last_advertised_addresses = None
415
+ self._start_args = None
416
+
417
+ async def _close_zeroconf_locked(self) -> None:
336
418
  zeroconf = self._zeroconf
337
419
  info = self._service_info
338
420
  self._zeroconf = None
@@ -345,3 +427,91 @@ class MdnsAdvertiser:
345
427
  await zeroconf.async_unregister_service(info)
346
428
  finally:
347
429
  await zeroconf.async_close()
430
+
431
+ async def _reconfigure(self) -> None:
432
+ async with self._lock:
433
+ if self._start_args is None:
434
+ return
435
+ old_addresses = self._last_advertised_addresses
436
+ advertisement = build_advertisement(**self._start_args)
437
+ if advertisement is None:
438
+ logger.info(
439
+ "mDNS reconfigure skipped: build_advertisement returned None "
440
+ "(transient empty address state); refresh loop continues "
441
+ "reason=transient_empty_address_state old=%s",
442
+ old_addresses,
443
+ )
444
+ return
445
+
446
+ # Clear the cached advertised set BEFORE closing so that even a
447
+ # failure inside ``_close_zeroconf_locked()`` itself (e.g. the
448
+ # old interface has vanished mid-cycle and ``async_unregister``
449
+ # / ``async_close`` raise) still leaves the refresh loop in a
450
+ # "must retry" state. If the subsequent close raises, or the
451
+ # re-registration raises, or the host IP later reverts to
452
+ # ``old_addresses``, the refresh loop's
453
+ # ``current != _last_advertised_addresses`` check would
454
+ # otherwise compare against the stale value and stay quiet —
455
+ # the advertisement would remain dead until a manual restart.
456
+ # Setting this to ``None`` here guarantees the next refresh
457
+ # tick observes a divergence and retries registration
458
+ # regardless of which address state the host happens to be in.
459
+ self._last_advertised_addresses = None
460
+ await self._close_zeroconf_locked()
461
+ await self._register_advertisement_locked(advertisement)
462
+ new_addresses = tuple(advertisement.parsed_addresses)
463
+ self._last_advertised_addresses = new_addresses
464
+ registered_name = getattr(
465
+ self._service_info,
466
+ "name",
467
+ advertisement.service_name,
468
+ )
469
+ logger.info(
470
+ "mDNS reconfigured: reason=address_changed old=%s new=%s "
471
+ "registered_name=%s",
472
+ old_addresses,
473
+ new_addresses,
474
+ registered_name,
475
+ )
476
+
477
+ async def _refresh_loop(self) -> None:
478
+ while True:
479
+ try:
480
+ await asyncio.sleep(self._refresh_interval)
481
+ start_args = self._start_args
482
+ if start_args is None:
483
+ continue
484
+ host = start_args["host"]
485
+ current_addresses = tuple(_resolve_addresses_for_host(host))
486
+ old_addresses = self._last_advertised_addresses
487
+ if current_addresses == old_addresses:
488
+ continue
489
+
490
+ logger.info(
491
+ "mDNS refresh observed address change: reason=address_changed "
492
+ "old=%s new=%s debounce=started",
493
+ old_addresses,
494
+ current_addresses,
495
+ )
496
+ await asyncio.sleep(self._refresh_interval)
497
+ start_args = self._start_args
498
+ if start_args is None:
499
+ continue
500
+ host = start_args["host"]
501
+ confirmed_addresses = tuple(_resolve_addresses_for_host(host))
502
+ if confirmed_addresses != current_addresses:
503
+ logger.info(
504
+ "mDNS refresh dropped transient address change: "
505
+ "reason=debounce_mismatch old=%s first=%s second=%s",
506
+ old_addresses,
507
+ current_addresses,
508
+ confirmed_addresses,
509
+ )
510
+ continue
511
+
512
+ await self._reconfigure()
513
+ except asyncio.CancelledError:
514
+ return
515
+ except Exception:
516
+ logger.exception("mDNS refresh loop error; continuing")
517
+ continue
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import math
6
7
  import os
7
8
  import socket
9
+ import subprocess
8
10
  import sys
9
11
  import uuid
10
12
  from datetime import datetime, timezone
@@ -13,6 +15,8 @@ from typing import Literal, TypedDict
13
15
 
14
16
  LOCK_DIR = Path.home() / ".stackchan-mcp"
15
17
  LOCK_PATH = LOCK_DIR / "owner.lock"
18
+ PROC_START_TOLERANCE_SECONDS = 2.0
19
+ _PROC_ROOT = Path("/proc")
16
20
 
17
21
 
18
22
  LockMode = Literal["stdio", "streamable-http"]
@@ -29,6 +33,7 @@ class LockInfo(_BaseLockInfo, total=False):
29
33
  mode: LockMode
30
34
  http_endpoint: str | None
31
35
  started_by: str | None
36
+ proc_start_epoch: float
32
37
 
33
38
 
34
39
  class OwnershipError(RuntimeError):
@@ -65,6 +70,101 @@ def is_pid_alive(pid: int) -> bool:
65
70
  return True
66
71
 
67
72
 
73
+ def get_process_start_time(pid: int) -> float | None:
74
+ """Return the kernel-reported process start time as epoch seconds.
75
+
76
+ Supported on Linux (``/proc``) and macOS (``ps lstart``). On other
77
+ platforms — notably Windows — this returns ``None``, so the identity
78
+ check is skipped and stale-lock detection falls back to the PID-only
79
+ liveness check (the pre-#253 behavior). Windows process-identity
80
+ verification is deferred per Issue #253's acceptance criteria.
81
+ """
82
+ if pid <= 0:
83
+ return None
84
+ if sys.platform.startswith("linux"):
85
+ return _get_process_start_time_linux(pid)
86
+ if sys.platform == "darwin":
87
+ return _get_process_start_time_macos(pid)
88
+ return None
89
+
90
+
91
+ def _get_process_start_time_linux(pid: int) -> float | None:
92
+ try:
93
+ stat_text = (_PROC_ROOT / str(pid) / "stat").read_text(encoding="utf-8")
94
+ proc_stat_text = (_PROC_ROOT / "stat").read_text(encoding="utf-8")
95
+ ticks_per_second = os.sysconf("SC_CLK_TCK")
96
+ except (OSError, ValueError):
97
+ return None
98
+
99
+ if not isinstance(ticks_per_second, int) or ticks_per_second <= 0:
100
+ return None
101
+
102
+ try:
103
+ after_comm = stat_text.rsplit(")", 1)[1].strip()
104
+ fields = after_comm.split()
105
+ start_ticks = int(fields[19])
106
+ except (IndexError, ValueError):
107
+ return None
108
+
109
+ boot_time: int | None = None
110
+ for line in proc_stat_text.splitlines():
111
+ parts = line.split()
112
+ if len(parts) == 2 and parts[0] == "btime":
113
+ try:
114
+ boot_time = int(parts[1])
115
+ except ValueError:
116
+ return None
117
+ break
118
+ if boot_time is None:
119
+ return None
120
+
121
+ return float(boot_time) + (float(start_ticks) / float(ticks_per_second))
122
+
123
+
124
+ def _get_process_start_time_macos(pid: int) -> float | None:
125
+ env = os.environ.copy()
126
+ env["LC_ALL"] = "C"
127
+ try:
128
+ result = subprocess.run(
129
+ ["ps", "-o", "lstart=", "-p", str(pid)],
130
+ capture_output=True,
131
+ check=False,
132
+ env=env,
133
+ text=True,
134
+ )
135
+ except OSError:
136
+ return None
137
+
138
+ output = result.stdout.strip()
139
+ if not output:
140
+ return None
141
+
142
+ line = output.splitlines()[0].strip()
143
+ try:
144
+ started = datetime.strptime(line, "%a %b %d %H:%M:%S %Y")
145
+ except ValueError:
146
+ return None
147
+ return started.timestamp()
148
+
149
+
150
+ def _coerce_proc_start_epoch(value: object) -> float | None:
151
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
152
+ return None
153
+ proc_start_epoch = float(value)
154
+ if not math.isfinite(proc_start_epoch):
155
+ return None
156
+ return proc_start_epoch
157
+
158
+
159
+ def _process_identity_matches(pid: int, expected_start: float | None) -> bool:
160
+ if expected_start is None:
161
+ return True
162
+ actual_start = get_process_start_time(pid)
163
+ if actual_start is None:
164
+ return True
165
+ return abs(actual_start - expected_start) <= PROC_START_TOLERANCE_SECONDS
166
+
167
+
68
168
  def _is_pid_alive_windows(pid: int) -> bool:
69
169
  import ctypes
70
170
  import ctypes.wintypes
@@ -152,6 +252,11 @@ def read_lock(path: Path = LOCK_PATH) -> LockInfo | None:
152
252
  if started_by is None or isinstance(started_by, str):
153
253
  info["started_by"] = started_by
154
254
 
255
+ if "proc_start_epoch" in raw:
256
+ proc_start_epoch = _coerce_proc_start_epoch(raw["proc_start_epoch"])
257
+ if proc_start_epoch is not None:
258
+ info["proc_start_epoch"] = proc_start_epoch
259
+
155
260
  return info
156
261
 
157
262
 
@@ -178,10 +283,11 @@ def acquire_lock(
178
283
  ) -> LockInfo:
179
284
  """Acquire the ownership lock. Raise OwnershipError on refuse.
180
285
 
181
- The default stdio-mode call writes the original #177 lock shape so
182
- older lock readers and ``stackchan-mcp --check`` output remain
183
- compatible. Daemon transports can attach optional metadata for
184
- diagnostics without changing the atomic hardlink claim.
286
+ The default stdio-mode call writes the original #177 lock base fields
287
+ plus additive process identity metadata. Older lock readers remain
288
+ compatible because the new field is optional JSON. Daemon transports
289
+ can attach optional metadata for diagnostics without changing the
290
+ atomic hardlink claim.
185
291
  """
186
292
  if mode not in ("stdio", "streamable-http"):
187
293
  raise ValueError(f"unsupported lock mode: {mode!r}")
@@ -195,25 +301,39 @@ def acquire_lock(
195
301
  existing = read_lock(path)
196
302
  if existing is not None:
197
303
  if is_pid_alive(existing["pid"]):
198
- raise OwnershipError(
199
- "stackchan-mcp: device already owned by "
200
- f"{existing['owner_id']} "
201
- f"(pid {existing['pid']}, since {existing['start_ts']})"
304
+ if _process_identity_matches(
305
+ existing["pid"], existing.get("proc_start_epoch")
306
+ ):
307
+ raise OwnershipError(
308
+ "stackchan-mcp: device already owned by "
309
+ f"{existing['owner_id']} "
310
+ f"(pid {existing['pid']}, since {existing['start_ts']})"
311
+ )
312
+ print(
313
+ "stackchan-mcp: removed stale lock from recycled pid "
314
+ f"{existing['pid']}",
315
+ file=sys.stderr,
316
+ )
317
+ else:
318
+ print(
319
+ "stackchan-mcp: removed stale lock from dead pid "
320
+ f"{existing['pid']}",
321
+ file=sys.stderr,
202
322
  )
203
- print(
204
- f"stackchan-mcp: removed stale lock from dead pid {existing['pid']}",
205
- file=sys.stderr,
206
- )
207
323
  path.unlink(missing_ok=True)
208
324
  elif path.exists():
209
325
  path.unlink()
210
326
 
327
+ pid = os.getpid()
328
+ proc_start_epoch = get_process_start_time(pid)
211
329
  info: LockInfo = {
212
330
  "owner_id": owner_id,
213
- "pid": os.getpid(),
331
+ "pid": pid,
214
332
  "start_ts": _now_iso(),
215
333
  "host": socket.gethostname(),
216
334
  }
335
+ if proc_start_epoch is not None:
336
+ info["proc_start_epoch"] = proc_start_epoch
217
337
  if mode != "stdio":
218
338
  info["mode"] = mode
219
339
  if http_endpoint is not None:
@@ -246,8 +366,9 @@ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
246
366
  """Remove the lock file only if it still belongs to ``info``.
247
367
 
248
368
  Returns ``True`` if the lock was removed, ``False`` if the on-disk
249
- lock has a different ``owner_id`` / ``pid`` / ``start_ts`` or no
250
- longer exists. This is the owner-scoped counterpart to
369
+ lock has a different ``owner_id`` / ``pid`` / ``start_ts`` /
370
+ ``proc_start_epoch`` or no longer exists. This is the owner-scoped
371
+ counterpart to
251
372
  :func:`release_lock` and is intended for cleanup paths (``finally``
252
373
  blocks, ``atexit.register``) where the caller may have lost ownership
253
374
  between claim and cleanup — for example after the gateway exited and
@@ -263,6 +384,12 @@ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
263
384
  or existing.get("start_ts") != info.get("start_ts")
264
385
  ):
265
386
  return False
387
+ if (
388
+ "proc_start_epoch" in existing
389
+ and "proc_start_epoch" in info
390
+ and existing["proc_start_epoch"] != info["proc_start_epoch"]
391
+ ):
392
+ return False
266
393
  try:
267
394
  path.unlink()
268
395
  return True