chibi-mcp 0.1.1__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.
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ venv/
7
+ .venv/
8
+ env/
9
+ .env
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 soccz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: chibi-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server for tteoki — a Korean rice cake desktop character that visualizes Claude Code session state
5
+ Project-URL: Homepage, https://github.com/soccz/chibi-mcp
6
+ Project-URL: Repository, https://github.com/soccz/chibi-mcp
7
+ Project-URL: Issues, https://github.com/soccz/chibi-mcp/issues
8
+ Project-URL: Release Notes, https://github.com/soccz/chibi-mcp/releases
9
+ Author: soccz
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: claude-code,desktop-pet,garaetteok,korean,mcp,tteoki
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Monitoring
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: fastmcp>=3.2.0
23
+ Requires-Dist: psutil>=5.9.0
24
+ Requires-Dist: websockets>=14.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # chibi-mcp (server)
32
+
33
+ MCP server for **tteoki** — a Korean rice cake (가래떡) desktop character that visualizes Claude Code session state.
34
+
35
+ The server has two jobs running together in one process:
36
+ 1. **MCP server (stdio)** — Claude Code / Codex calls these tools to interact with tteoki.
37
+ 2. **WebSocket server (`ws://127.0.0.1:9876`)** — pushes state snapshots and events to the desktop app.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install chibi-mcp
43
+ ```
44
+
45
+ (For local development from this repo, see "Develop" below.)
46
+
47
+ ## Register with Claude Code
48
+
49
+ ```bash
50
+ claude mcp add chibi -- chibi-mcp
51
+ ```
52
+
53
+ Then launch the chibi-desktop app — it connects to the WebSocket and renders tteoki.
54
+
55
+ ## MCP tools
56
+
57
+ | Tool | Description |
58
+ |---|---|
59
+ | `get_pet_state` | Returns mood, system metrics (CPU/RAM/battery), counters, timing. Counts as a Claude interaction (may trigger a slice every N calls). |
60
+ | `pet_say(text)` | Make tteoki say something in a speech bubble. |
61
+ | `slice_now` | Force a slice (resets the lengthen cycle, fires a slice event). |
62
+ | `set_slice_interval(n)` | Change how often (every N Claude tool calls) the auto-slice fires. Default: 10. |
63
+
64
+ ## WebSocket protocol (server → desktop)
65
+
66
+ JSON messages broadcast to all connected desktop clients:
67
+
68
+ ```jsonc
69
+ { "type": "state",
70
+ "payload": {
71
+ "mood": "calm | panting | drowsy | lonely | happy | surprised | joyful",
72
+ "system": { "cpu_percent": 12.3, "ram_percent": 51.2, "battery_percent": 84.0, "battery_plugged": false },
73
+ "counters": { "calls_total": 23, "calls_since_slice": 3, "slice_interval": 10, "slices_today": 2 },
74
+ "timing": { "session_seconds": 1842, "idle_seconds": 7 }
75
+ }
76
+ }
77
+
78
+ { "type": "say", "text": "오늘도 같이 코딩!" }
79
+
80
+ { "type": "slice" }
81
+ ```
82
+
83
+ State is pushed every 2 seconds. `say` and `slice` are pushed on demand.
84
+
85
+ ## Environment variables
86
+
87
+ | Var | Default | Purpose |
88
+ |---|---|---|
89
+ | `CHIBI_WS_HOST` | `127.0.0.1` | WebSocket bind host |
90
+ | `CHIBI_WS_PORT` | `9876` | WebSocket bind port |
91
+ | `CHIBI_LOG_LEVEL` | `INFO` | Logging level |
92
+
93
+ ## Develop
94
+
95
+ ```bash
96
+ cd server
97
+ python3.12 -m venv venv
98
+ source venv/bin/activate
99
+ pip install -e ".[dev]"
100
+ pytest
101
+ ```
102
+
103
+ Run directly (without Claude Code) for the WebSocket side only:
104
+
105
+ ```bash
106
+ CHIBI_LOG_LEVEL=DEBUG python -m chibi_mcp
107
+ ```
108
+
109
+ The stdio MCP side will wait for a client on stdin/stdout, so to test the WebSocket alone, connect a simple ws client to `ws://localhost:9876` while the process runs.
110
+
111
+ ## Design notes
112
+
113
+ - **Counter resets only when the process restarts** — intentional "today's-work" scope.
114
+ - **All metrics are local** — no network calls, no telemetry, no accounts.
115
+ - **Slice trigger is Claude-call-based** (not wall-clock) — measures actual work, not just time sitting.
116
+
117
+ See `../SPEC.md` and `../CHARACTER_DESIGN.md` for the broader project context.
@@ -0,0 +1,87 @@
1
+ # chibi-mcp (server)
2
+
3
+ MCP server for **tteoki** — a Korean rice cake (가래떡) desktop character that visualizes Claude Code session state.
4
+
5
+ The server has two jobs running together in one process:
6
+ 1. **MCP server (stdio)** — Claude Code / Codex calls these tools to interact with tteoki.
7
+ 2. **WebSocket server (`ws://127.0.0.1:9876`)** — pushes state snapshots and events to the desktop app.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install chibi-mcp
13
+ ```
14
+
15
+ (For local development from this repo, see "Develop" below.)
16
+
17
+ ## Register with Claude Code
18
+
19
+ ```bash
20
+ claude mcp add chibi -- chibi-mcp
21
+ ```
22
+
23
+ Then launch the chibi-desktop app — it connects to the WebSocket and renders tteoki.
24
+
25
+ ## MCP tools
26
+
27
+ | Tool | Description |
28
+ |---|---|
29
+ | `get_pet_state` | Returns mood, system metrics (CPU/RAM/battery), counters, timing. Counts as a Claude interaction (may trigger a slice every N calls). |
30
+ | `pet_say(text)` | Make tteoki say something in a speech bubble. |
31
+ | `slice_now` | Force a slice (resets the lengthen cycle, fires a slice event). |
32
+ | `set_slice_interval(n)` | Change how often (every N Claude tool calls) the auto-slice fires. Default: 10. |
33
+
34
+ ## WebSocket protocol (server → desktop)
35
+
36
+ JSON messages broadcast to all connected desktop clients:
37
+
38
+ ```jsonc
39
+ { "type": "state",
40
+ "payload": {
41
+ "mood": "calm | panting | drowsy | lonely | happy | surprised | joyful",
42
+ "system": { "cpu_percent": 12.3, "ram_percent": 51.2, "battery_percent": 84.0, "battery_plugged": false },
43
+ "counters": { "calls_total": 23, "calls_since_slice": 3, "slice_interval": 10, "slices_today": 2 },
44
+ "timing": { "session_seconds": 1842, "idle_seconds": 7 }
45
+ }
46
+ }
47
+
48
+ { "type": "say", "text": "오늘도 같이 코딩!" }
49
+
50
+ { "type": "slice" }
51
+ ```
52
+
53
+ State is pushed every 2 seconds. `say` and `slice` are pushed on demand.
54
+
55
+ ## Environment variables
56
+
57
+ | Var | Default | Purpose |
58
+ |---|---|---|
59
+ | `CHIBI_WS_HOST` | `127.0.0.1` | WebSocket bind host |
60
+ | `CHIBI_WS_PORT` | `9876` | WebSocket bind port |
61
+ | `CHIBI_LOG_LEVEL` | `INFO` | Logging level |
62
+
63
+ ## Develop
64
+
65
+ ```bash
66
+ cd server
67
+ python3.12 -m venv venv
68
+ source venv/bin/activate
69
+ pip install -e ".[dev]"
70
+ pytest
71
+ ```
72
+
73
+ Run directly (without Claude Code) for the WebSocket side only:
74
+
75
+ ```bash
76
+ CHIBI_LOG_LEVEL=DEBUG python -m chibi_mcp
77
+ ```
78
+
79
+ The stdio MCP side will wait for a client on stdin/stdout, so to test the WebSocket alone, connect a simple ws client to `ws://localhost:9876` while the process runs.
80
+
81
+ ## Design notes
82
+
83
+ - **Counter resets only when the process restarts** — intentional "today's-work" scope.
84
+ - **All metrics are local** — no network calls, no telemetry, no accounts.
85
+ - **Slice trigger is Claude-call-based** (not wall-clock) — measures actual work, not just time sitting.
86
+
87
+ See `../SPEC.md` and `../CHARACTER_DESIGN.md` for the broader project context.
@@ -0,0 +1,3 @@
1
+ """chibi-mcp — tteoki desktop character MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """chibi-mcp entry point.
2
+
3
+ Runs the FastMCP server (stdio transport for Claude Code) AND a localhost
4
+ WebSocket server (for the desktop app) in the same asyncio loop.
5
+
6
+ Install:
7
+ pip install chibi-mcp
8
+
9
+ Register with Claude Code:
10
+ claude mcp add chibi -- chibi-mcp
11
+
12
+ Then launch the chibi-desktop app, which will connect to ws://localhost:9876.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ import os
20
+ import signal
21
+ import sys
22
+ from contextlib import suppress
23
+
24
+ from .server import mcp
25
+ from .ws_server import DEFAULT_WS_HOST, DEFAULT_WS_PORT, run_ws_server
26
+
27
+
28
+ def _setup_logging() -> None:
29
+ level_name = os.environ.get("CHIBI_LOG_LEVEL", "INFO").upper()
30
+ level = getattr(logging, level_name, logging.INFO)
31
+ # Log to stderr — stdout is reserved for MCP stdio transport
32
+ logging.basicConfig(
33
+ level=level,
34
+ stream=sys.stderr,
35
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
36
+ )
37
+
38
+
39
+ async def _run_concurrent() -> None:
40
+ """Run MCP (stdio) and WebSocket server concurrently."""
41
+ host = os.environ.get("CHIBI_WS_HOST", DEFAULT_WS_HOST)
42
+ port = int(os.environ.get("CHIBI_WS_PORT", DEFAULT_WS_PORT))
43
+
44
+ ws_task = asyncio.create_task(run_ws_server(host=host, port=port))
45
+ # FastMCP's run_stdio_async is the asyncio variant of mcp.run()
46
+ mcp_task = asyncio.create_task(mcp.run_stdio_async())
47
+
48
+ # Graceful shutdown on SIGTERM/SIGINT
49
+ loop = asyncio.get_running_loop()
50
+ stop = loop.create_future()
51
+
52
+ def _signal_stop() -> None:
53
+ if not stop.done():
54
+ stop.set_result(None)
55
+
56
+ for sig in (signal.SIGINT, signal.SIGTERM):
57
+ with suppress(NotImplementedError):
58
+ loop.add_signal_handler(sig, _signal_stop)
59
+
60
+ try:
61
+ # Wait for any of: MCP exit, WS exit, signal
62
+ done, _pending = await asyncio.wait(
63
+ {mcp_task, ws_task, stop},
64
+ return_when=asyncio.FIRST_COMPLETED,
65
+ )
66
+ for t in done:
67
+ exc = t.exception() if not t.cancelled() else None
68
+ if exc:
69
+ logging.getLogger(__name__).error("task failed: %r", exc)
70
+ finally:
71
+ for t in (mcp_task, ws_task):
72
+ if not t.done():
73
+ t.cancel()
74
+ with suppress(asyncio.CancelledError):
75
+ await asyncio.gather(mcp_task, ws_task, return_exceptions=True)
76
+
77
+
78
+ def main() -> int:
79
+ _setup_logging()
80
+ log = logging.getLogger("chibi_mcp")
81
+ log.info("chibi-mcp starting (stdio MCP + ws://%s:%d)",
82
+ os.environ.get("CHIBI_WS_HOST", DEFAULT_WS_HOST),
83
+ int(os.environ.get("CHIBI_WS_PORT", DEFAULT_WS_PORT)))
84
+ with suppress(KeyboardInterrupt):
85
+ asyncio.run(_run_concurrent())
86
+ log.info("chibi-mcp stopped")
87
+ return 0
88
+
89
+
90
+ if __name__ == "__main__":
91
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ """FastMCP server — tools Claude Code calls to interact with tteoki."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from fastmcp import FastMCP
9
+
10
+ from .state import get_state
11
+ from .ws_server import get_broadcaster
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ mcp = FastMCP("chibi-mcp")
16
+
17
+
18
+ # Limits for incoming MCP tool inputs. Claude may emit large strings — we
19
+ # defend the desktop UI from rendering pathological payloads.
20
+ MAX_SAY_LEN = 200
21
+ SAY_CONTROL_CHARS = "".join(chr(c) for c in range(32) if c not in (9, 10)) # keep tab+LF
22
+
23
+ # Holds references to fire-and-forget asyncio tasks so they aren't garbage
24
+ # collected mid-flight. (RUF006 — Python may drop tasks that have no strong
25
+ # reference, silently dropping the WebSocket broadcast.)
26
+ _PENDING_TASKS: set[asyncio.Task] = set()
27
+
28
+
29
+ def _fire_and_forget(coro) -> bool:
30
+ """Schedule `coro` on the running loop, keep a hard reference until done.
31
+
32
+ Returns True if scheduled, False if there's no running loop in the current
33
+ thread (which means MCP is being called synchronously without an event loop).
34
+ """
35
+ try:
36
+ loop = asyncio.get_running_loop()
37
+ except RuntimeError:
38
+ return False
39
+ task = loop.create_task(coro)
40
+ _PENDING_TASKS.add(task)
41
+ task.add_done_callback(_PENDING_TASKS.discard)
42
+ return True
43
+
44
+
45
+ def _sanitize_say(text: str) -> str:
46
+ if not isinstance(text, str):
47
+ text = str(text)
48
+ # Drop control characters (except tab and newline)
49
+ cleaned = text.translate({ord(c): None for c in SAY_CONTROL_CHARS})
50
+ # Collapse to single line for speech bubble
51
+ cleaned = cleaned.replace("\r", " ").replace("\n", " ").strip()
52
+ if len(cleaned) > MAX_SAY_LEN:
53
+ cleaned = cleaned[: MAX_SAY_LEN - 1] + "…"
54
+ return cleaned
55
+
56
+
57
+ def _record_call_and_maybe_slice(force_slice: bool = False) -> dict:
58
+ """Increment counter; if slice milestone hit, broadcast a slice event."""
59
+ state = get_state()
60
+ result = state.record_call(force_slice=force_slice)
61
+ if result["sliced"]:
62
+ broadcaster = get_broadcaster()
63
+ scheduled = _fire_and_forget(broadcaster.broadcast({"type": "slice"}))
64
+ if not scheduled:
65
+ log.debug("slice event skipped (no running loop)")
66
+ return result
67
+
68
+
69
+ @mcp.tool()
70
+ def get_pet_state() -> dict:
71
+ """Return tteoki's current state: mood, system metrics, counters, timing.
72
+
73
+ The desktop app reads this to render the character. Calling this tool
74
+ counts as a Claude interaction and may trigger a slice every N calls.
75
+ """
76
+ counter = _record_call_and_maybe_slice()
77
+ state = get_state()
78
+ snapshot = state.snapshot()
79
+ snapshot["last_call_result"] = counter
80
+ return snapshot
81
+
82
+
83
+ @mcp.tool()
84
+ def pet_say(text: str) -> dict:
85
+ """Make tteoki say something via a speech bubble in the desktop app.
86
+
87
+ Args:
88
+ text: short message (≤ 200 chars; longer text is truncated with "…").
89
+ Control characters and newlines are stripped to keep the bubble
90
+ renderable.
91
+
92
+ Returns:
93
+ Confirmation dict with the sanitized text and broadcast status.
94
+ """
95
+ safe = _sanitize_say(text)
96
+ counter = _record_call_and_maybe_slice()
97
+
98
+ broadcaster = get_broadcaster()
99
+ broadcasted = _fire_and_forget(broadcaster.broadcast({"type": "say", "text": safe}))
100
+
101
+ return {
102
+ "spoken": safe,
103
+ "broadcasted": broadcasted,
104
+ "counter": counter,
105
+ }
106
+
107
+
108
+ @mcp.tool()
109
+ def slice_now() -> dict:
110
+ """Manually trigger a slice (force the lengthen-cycle to reset).
111
+
112
+ Useful when the user wants to mark a milestone without waiting for the
113
+ N-call automatic trigger.
114
+ """
115
+ counter = _record_call_and_maybe_slice(force_slice=True)
116
+ return {
117
+ "forced": True,
118
+ "counter": counter,
119
+ }
120
+
121
+
122
+ @mcp.tool()
123
+ def set_slice_interval(n: int) -> dict:
124
+ """Change how often (every N Claude tool calls) tteoki gets sliced.
125
+
126
+ Args:
127
+ n: positive integer. Default is 10. Suggested values: 5, 10, 25, 50, 100.
128
+ """
129
+ if n < 1:
130
+ raise ValueError("slice interval must be ≥ 1")
131
+ state = get_state()
132
+ old = state.slice_interval
133
+ state.slice_interval = n
134
+ return {"previous": old, "current": n}
@@ -0,0 +1,154 @@
1
+ """Character state — derives tteoki's mood from system metrics and call counter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from threading import Lock
9
+
10
+ from .system_info import SystemSnapshot, read_snapshot
11
+
12
+
13
+ class Mood(StrEnum):
14
+ CALM = "calm" # 평온 — default
15
+ PANTING = "panting" # 헐떡 — CPU 80%+
16
+ DROWSY = "drowsy" # 졸림 — battery < 20% unplugged
17
+ LONELY = "lonely" # 시무룩 — long idle (no Claude calls)
18
+ HAPPY = "happy" # 기쁨 — recent Claude call
19
+ SURPRISED = "surprised" # 놀람 — CPU sudden spike
20
+ JOYFUL = "joyful" # 행복 — milestone (slice triggered)
21
+
22
+
23
+ # Slice trigger: every N Claude tool calls (user-decided default = 10)
24
+ DEFAULT_SLICE_INTERVAL = 10
25
+ LONELY_IDLE_SECONDS = 30 * 60 # 30 minutes
26
+ HAPPY_WINDOW_SECONDS = 30
27
+ SURPRISE_DELTA = 30.0 # CPU jump of 30%+ within one tick = surprise
28
+
29
+
30
+ @dataclass
31
+ class TteokiState:
32
+ """In-memory state of the tteoki character.
33
+
34
+ Thread-safe via a single lock. Counters reset only when the server process restarts
35
+ (today's-work scope, intentional).
36
+ """
37
+
38
+ slice_interval: int = DEFAULT_SLICE_INTERVAL
39
+ _lock: Lock = field(default_factory=Lock, repr=False)
40
+
41
+ # Counters
42
+ call_count: int = 0 # cumulative Claude tool calls since server start
43
+ calls_since_slice: int = 0 # resets on each slice
44
+ slices_today: int = 0 # cumulative slice events since server start
45
+
46
+ # Timing
47
+ started_at: float = field(default_factory=time.time)
48
+ last_call_at: float | None = None
49
+
50
+ # CPU spike detection
51
+ last_cpu: float = 0.0
52
+
53
+ def record_call(self, force_slice: bool = False) -> dict:
54
+ """Increment call counters. Returns a dict signaling if a slice fired.
55
+
56
+ When `force_slice` is True, the slice milestone is triggered regardless
57
+ of how many calls have accumulated. Used by the manual `slice_now` tool.
58
+ """
59
+ with self._lock:
60
+ self.call_count += 1
61
+ self.calls_since_slice += 1
62
+ self.last_call_at = time.time()
63
+ sliced = False
64
+ if force_slice or self.calls_since_slice >= self.slice_interval:
65
+ self.calls_since_slice = 0
66
+ self.slices_today += 1
67
+ sliced = True
68
+ return {
69
+ "call_count": self.call_count,
70
+ "calls_since_slice": self.calls_since_slice,
71
+ "slices_today": self.slices_today,
72
+ "sliced": sliced,
73
+ }
74
+
75
+ def compute_mood(self, snap: SystemSnapshot) -> Mood:
76
+ """Derive mood from current snapshot and timing history."""
77
+ now = time.time()
78
+
79
+ with self._lock:
80
+ last_call = self.last_call_at
81
+ last_cpu = self.last_cpu
82
+ self.last_cpu = snap.cpu_percent
83
+
84
+ # Battery drowsiness wins if unplugged and low
85
+ if (
86
+ snap.battery_percent is not None
87
+ and snap.battery_percent < 20
88
+ and snap.battery_plugged is False
89
+ ):
90
+ return Mood.DROWSY
91
+
92
+ # CPU sudden spike (≥30% jump)
93
+ if snap.cpu_percent - last_cpu >= SURPRISE_DELTA and snap.cpu_percent > 50:
94
+ return Mood.SURPRISED
95
+
96
+ # Sustained high CPU
97
+ if snap.cpu_percent >= 80:
98
+ return Mood.PANTING
99
+
100
+ # Recent Claude call → happy
101
+ if last_call is not None and (now - last_call) <= HAPPY_WINDOW_SECONDS:
102
+ return Mood.HAPPY
103
+
104
+ # Long idle → lonely
105
+ if last_call is None:
106
+ session_age = now - self.started_at
107
+ if session_age > LONELY_IDLE_SECONDS:
108
+ return Mood.LONELY
109
+ elif (now - last_call) > LONELY_IDLE_SECONDS:
110
+ return Mood.LONELY
111
+
112
+ return Mood.CALM
113
+
114
+ def snapshot(self) -> dict:
115
+ """Return a JSON-serializable snapshot of full state (for MCP/WebSocket)."""
116
+ snap = read_snapshot(interval=0.0)
117
+ mood = self.compute_mood(snap)
118
+
119
+ with self._lock:
120
+ now = time.time()
121
+ session_seconds = int(now - self.started_at)
122
+ idle_seconds = int(now - self.last_call_at) if self.last_call_at else None
123
+
124
+ return {
125
+ "mood": mood.value,
126
+ "system": snap.to_dict(),
127
+ "counters": {
128
+ "calls_total": self.call_count,
129
+ "calls_since_slice": self.calls_since_slice,
130
+ "slice_interval": self.slice_interval,
131
+ "slices_today": self.slices_today,
132
+ },
133
+ "timing": {
134
+ "session_seconds": session_seconds,
135
+ "idle_seconds": idle_seconds,
136
+ },
137
+ }
138
+
139
+
140
+ # Module-level singleton — one tteoki per server process
141
+ _STATE: TteokiState | None = None
142
+
143
+
144
+ def get_state() -> TteokiState:
145
+ global _STATE
146
+ if _STATE is None:
147
+ _STATE = TteokiState()
148
+ return _STATE
149
+
150
+
151
+ def reset_state_for_tests() -> None:
152
+ """Test helper — DO NOT call from runtime code."""
153
+ global _STATE
154
+ _STATE = None
@@ -0,0 +1,51 @@
1
+ """System metrics (CPU, RAM, battery) via psutil."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import psutil
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SystemSnapshot:
12
+ cpu_percent: float
13
+ ram_percent: float
14
+ battery_percent: float | None # None when no battery (desktop)
15
+ battery_plugged: bool | None
16
+
17
+ def to_dict(self) -> dict:
18
+ return {
19
+ "cpu_percent": round(self.cpu_percent, 1),
20
+ "ram_percent": round(self.ram_percent, 1),
21
+ "battery_percent": (
22
+ round(self.battery_percent, 1) if self.battery_percent is not None else None
23
+ ),
24
+ "battery_plugged": self.battery_plugged,
25
+ }
26
+
27
+
28
+ def read_snapshot(interval: float = 0.0) -> SystemSnapshot:
29
+ """Read current CPU/RAM/battery state.
30
+
31
+ interval=0.0 returns the cached value since last call (non-blocking).
32
+ interval>0 measures CPU over that window (blocking).
33
+
34
+ Battery is best-effort: psutil.sensors_battery() can raise
35
+ NotImplementedError on some Linux/VM/server environments. Treat any
36
+ failure as "no battery" rather than crashing the server.
37
+ """
38
+ cpu = psutil.cpu_percent(interval=interval)
39
+ ram = psutil.virtual_memory().percent
40
+ try:
41
+ bat = psutil.sensors_battery()
42
+ except (NotImplementedError, AttributeError, OSError):
43
+ bat = None
44
+ if bat is None:
45
+ return SystemSnapshot(cpu_percent=cpu, ram_percent=ram, battery_percent=None, battery_plugged=None)
46
+ return SystemSnapshot(
47
+ cpu_percent=cpu,
48
+ ram_percent=ram,
49
+ battery_percent=bat.percent,
50
+ battery_plugged=bat.power_plugged,
51
+ )
@@ -0,0 +1,124 @@
1
+ """WebSocket server — pushes tteoki state to the desktop app.
2
+
3
+ Protocol (JSON messages, server → client):
4
+ {"type": "state", "payload": {...full state snapshot...}}
5
+ {"type": "say", "text": "..."}
6
+ {"type": "slice"} # fires when N-call milestone hits
7
+
8
+ The desktop app connects to ws://localhost:9876 and listens for events.
9
+
10
+ Uses the websockets >= 14 asyncio API (`websockets.asyncio.server`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import json
18
+ import logging
19
+
20
+ from websockets.asyncio.server import ServerConnection, serve
21
+ from websockets.exceptions import ConnectionClosed
22
+
23
+ from .state import get_state
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ DEFAULT_WS_HOST = "127.0.0.1"
28
+ DEFAULT_WS_PORT = 9876
29
+ STATE_PUSH_INTERVAL_SECONDS = 2.0 # snapshot push cadence
30
+
31
+
32
+ class TteokiBroadcaster:
33
+ """Holds connected desktop clients and broadcasts events."""
34
+
35
+ def __init__(self) -> None:
36
+ self._clients: set[ServerConnection] = set()
37
+ self._lock = asyncio.Lock()
38
+
39
+ async def register(self, ws: ServerConnection) -> None:
40
+ async with self._lock:
41
+ self._clients.add(ws)
42
+ log.info("ws client connected (%d total)", len(self._clients))
43
+
44
+ async def unregister(self, ws: ServerConnection) -> None:
45
+ async with self._lock:
46
+ self._clients.discard(ws)
47
+ log.info("ws client disconnected (%d total)", len(self._clients))
48
+
49
+ async def broadcast(self, message: dict) -> None:
50
+ payload = json.dumps(message)
51
+ async with self._lock:
52
+ clients = list(self._clients)
53
+ if not clients:
54
+ return
55
+ results = await asyncio.gather(
56
+ *(self._send_safe(c, payload) for c in clients), return_exceptions=True
57
+ )
58
+ for r in results:
59
+ if isinstance(r, Exception):
60
+ log.debug("broadcast send failed: %s", r)
61
+
62
+ @staticmethod
63
+ async def _send_safe(ws: ServerConnection, payload: str) -> None:
64
+ with contextlib.suppress(ConnectionClosed):
65
+ await ws.send(payload)
66
+
67
+
68
+ _BROADCASTER: TteokiBroadcaster | None = None
69
+
70
+
71
+ def get_broadcaster() -> TteokiBroadcaster:
72
+ global _BROADCASTER
73
+ if _BROADCASTER is None:
74
+ _BROADCASTER = TteokiBroadcaster()
75
+ return _BROADCASTER
76
+
77
+
78
+ def reset_broadcaster_for_tests() -> None:
79
+ """Test helper — DO NOT call from runtime code."""
80
+ global _BROADCASTER
81
+ _BROADCASTER = None
82
+
83
+
84
+ async def _handle_client(ws: ServerConnection) -> None:
85
+ broadcaster = get_broadcaster()
86
+ await broadcaster.register(ws)
87
+
88
+ # Send current state immediately on connect
89
+ state = get_state()
90
+ await ws.send(json.dumps({"type": "state", "payload": state.snapshot()}))
91
+
92
+ try:
93
+ async for _raw in ws:
94
+ # Desktop app may send pings or settings updates later; ignore for v0.1
95
+ pass
96
+ except ConnectionClosed:
97
+ pass
98
+ finally:
99
+ await broadcaster.unregister(ws)
100
+
101
+
102
+ async def _state_push_loop() -> None:
103
+ """Periodically push state to all connected clients."""
104
+ broadcaster = get_broadcaster()
105
+ state = get_state()
106
+ while True:
107
+ try:
108
+ await broadcaster.broadcast({"type": "state", "payload": state.snapshot()})
109
+ except Exception:
110
+ log.exception("state push failed")
111
+ await asyncio.sleep(STATE_PUSH_INTERVAL_SECONDS)
112
+
113
+
114
+ async def run_ws_server(host: str = DEFAULT_WS_HOST, port: int = DEFAULT_WS_PORT) -> None:
115
+ """Run WebSocket server + periodic state push concurrently."""
116
+ push_task = asyncio.create_task(_state_push_loop())
117
+ async with serve(_handle_client, host, port):
118
+ log.info("ws server listening on ws://%s:%d", host, port)
119
+ try:
120
+ await asyncio.Future() # run forever
121
+ finally:
122
+ push_task.cancel()
123
+ with contextlib.suppress(asyncio.CancelledError):
124
+ await push_task
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "chibi-mcp"
3
+ version = "0.1.1"
4
+ description = "MCP server for tteoki — a Korean rice cake desktop character that visualizes Claude Code session state"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "soccz" }]
9
+ keywords = ["mcp", "claude-code", "desktop-pet", "korean", "tteoki", "garaetteok"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ "Topic :: System :: Monitoring",
19
+ ]
20
+
21
+ dependencies = [
22
+ "fastmcp>=3.2.0",
23
+ "psutil>=5.9.0",
24
+ "websockets>=14.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/soccz/chibi-mcp"
29
+ Repository = "https://github.com/soccz/chibi-mcp"
30
+ Issues = "https://github.com/soccz/chibi-mcp/issues"
31
+ "Release Notes" = "https://github.com/soccz/chibi-mcp/releases"
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.23",
37
+ "ruff>=0.6",
38
+ ]
39
+
40
+ [project.scripts]
41
+ chibi-mcp = "chibi_mcp.__main__:main"
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["chibi_mcp"]
49
+
50
+ [tool.pytest.ini_options]
51
+ asyncio_mode = "auto"
52
+ testpaths = ["tests"]
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+ target-version = "py312"
57
+ extend-exclude = ["venv", "build", "dist"]
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ "E", # pycodestyle errors
62
+ "F", # pyflakes
63
+ "W", # pycodestyle warnings
64
+ "I", # isort
65
+ "UP", # pyupgrade
66
+ "B", # bugbear
67
+ "C4", # comprehensions
68
+ "SIM", # simplify
69
+ "RUF", # ruff-specific
70
+ ]
71
+ ignore = [
72
+ "E501", # line too long — handled by formatter context
73
+ "SIM117", # combining nested with statements isn't always clearer
74
+ ]
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ "tests/**" = ["B011"] # allow `assert` patterns in tests
File without changes
@@ -0,0 +1,41 @@
1
+ """Tests for server.py — input sanitization for MCP tool args."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chibi_mcp.server import MAX_SAY_LEN, _sanitize_say
6
+
7
+
8
+ def test_sanitize_short_text_passthrough():
9
+ assert _sanitize_say("hi") == "hi"
10
+ assert _sanitize_say("오늘도 같이 코딩!") == "오늘도 같이 코딩!"
11
+
12
+
13
+ def test_sanitize_truncates_long_text():
14
+ long = "a" * 1000
15
+ result = _sanitize_say(long)
16
+ assert len(result) == MAX_SAY_LEN
17
+ assert result.endswith("…")
18
+
19
+
20
+ def test_sanitize_strips_control_chars():
21
+ # \x00 NULL, \x07 BEL — should be stripped; tab and newline are collapsed to space
22
+ result = _sanitize_say("hi\x00there\x07world\nnewline\ttab")
23
+ assert "\x00" not in result
24
+ assert "\x07" not in result
25
+ assert "\n" not in result
26
+ # tab is preserved in SAY_CONTROL_CHARS exclusion but newline collapses to space
27
+ assert "hi" in result and "world" in result
28
+
29
+
30
+ def test_sanitize_collapses_newlines_for_single_line_bubble():
31
+ assert "\n" not in _sanitize_say("line1\nline2")
32
+ assert "\r" not in _sanitize_say("line1\r\nline2")
33
+
34
+
35
+ def test_sanitize_handles_non_string_input():
36
+ assert _sanitize_say(42) == "42" # type: ignore[arg-type]
37
+ assert _sanitize_say(None) == "None" # type: ignore[arg-type]
38
+
39
+
40
+ def test_sanitize_strips_surrounding_whitespace():
41
+ assert _sanitize_say(" hello ") == "hello"
@@ -0,0 +1,149 @@
1
+ """Tests for state.py — mood derivation and slice counter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+
10
+ from chibi_mcp.state import (
11
+ DEFAULT_SLICE_INTERVAL,
12
+ HAPPY_WINDOW_SECONDS,
13
+ LONELY_IDLE_SECONDS,
14
+ Mood,
15
+ TteokiState,
16
+ reset_state_for_tests,
17
+ )
18
+ from chibi_mcp.system_info import SystemSnapshot
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def _reset():
23
+ reset_state_for_tests()
24
+ yield
25
+ reset_state_for_tests()
26
+
27
+
28
+ def _snap(cpu=10.0, ram=40.0, bat=None, plugged=None) -> SystemSnapshot:
29
+ return SystemSnapshot(cpu_percent=cpu, ram_percent=ram, battery_percent=bat, battery_plugged=plugged)
30
+
31
+
32
+ # ---- slice counter ----
33
+
34
+
35
+ def test_slice_fires_every_default_interval():
36
+ state = TteokiState()
37
+ fired = []
38
+ for _ in range(DEFAULT_SLICE_INTERVAL):
39
+ r = state.record_call()
40
+ fired.append(r["sliced"])
41
+ # First N-1 calls don't slice; N-th does
42
+ assert fired == [False] * (DEFAULT_SLICE_INTERVAL - 1) + [True]
43
+ assert state.slices_today == 1
44
+ assert state.calls_since_slice == 0
45
+
46
+
47
+ def test_slice_interval_is_configurable():
48
+ state = TteokiState(slice_interval=3)
49
+ sliced = [state.record_call()["sliced"] for _ in range(6)]
50
+ assert sliced == [False, False, True, False, False, True]
51
+ assert state.slices_today == 2
52
+
53
+
54
+ def test_call_count_keeps_growing_across_slices():
55
+ state = TteokiState(slice_interval=2)
56
+ for _ in range(5):
57
+ state.record_call()
58
+ assert state.call_count == 5
59
+
60
+
61
+ # ---- force_slice (manual slice_now path) ----
62
+
63
+
64
+ def test_force_slice_fires_immediately():
65
+ state = TteokiState(slice_interval=100)
66
+ r = state.record_call(force_slice=True)
67
+ assert r["sliced"] is True
68
+ assert state.slices_today == 1
69
+ assert state.calls_since_slice == 0
70
+
71
+
72
+ def test_force_slice_does_not_affect_natural_counter():
73
+ state = TteokiState(slice_interval=3)
74
+ state.record_call()
75
+ state.record_call() # calls_since_slice = 2
76
+ state.record_call(force_slice=True) # forced — resets, +1 slice
77
+ assert state.slices_today == 1
78
+ # next 3 normal calls should slice again
79
+ sliced = [state.record_call()["sliced"] for _ in range(3)]
80
+ assert sliced == [False, False, True]
81
+ assert state.slices_today == 2
82
+
83
+
84
+ # ---- mood derivation ----
85
+
86
+
87
+ def test_default_mood_is_calm():
88
+ state = TteokiState()
89
+ assert state.compute_mood(_snap()) == Mood.CALM
90
+
91
+
92
+ def test_high_cpu_triggers_panting():
93
+ state = TteokiState()
94
+ state.last_cpu = 75.0 # so jump isn't enough for SURPRISED
95
+ assert state.compute_mood(_snap(cpu=85.0)) == Mood.PANTING
96
+
97
+
98
+ def test_sudden_cpu_spike_triggers_surprised():
99
+ state = TteokiState()
100
+ state.last_cpu = 5.0
101
+ assert state.compute_mood(_snap(cpu=60.0)) == Mood.SURPRISED
102
+
103
+
104
+ def test_low_battery_unplugged_triggers_drowsy():
105
+ state = TteokiState()
106
+ assert state.compute_mood(_snap(bat=15.0, plugged=False)) == Mood.DROWSY
107
+
108
+
109
+ def test_low_battery_but_plugged_is_not_drowsy():
110
+ state = TteokiState()
111
+ assert state.compute_mood(_snap(bat=15.0, plugged=True)) != Mood.DROWSY
112
+
113
+
114
+ def test_recent_call_triggers_happy():
115
+ state = TteokiState()
116
+ state.last_call_at = time.time() - 5
117
+ assert state.compute_mood(_snap()) == Mood.HAPPY
118
+
119
+
120
+ def test_long_idle_after_calls_triggers_lonely():
121
+ state = TteokiState()
122
+ state.last_call_at = time.time() - (LONELY_IDLE_SECONDS + 60)
123
+ assert state.compute_mood(_snap()) == Mood.LONELY
124
+
125
+
126
+ def test_long_session_without_any_call_triggers_lonely():
127
+ state = TteokiState()
128
+ state.started_at = time.time() - (LONELY_IDLE_SECONDS + 60)
129
+ state.last_call_at = None
130
+ assert state.compute_mood(_snap()) == Mood.LONELY
131
+
132
+
133
+ def test_happy_window_boundary():
134
+ state = TteokiState()
135
+ state.last_call_at = time.time() - (HAPPY_WINDOW_SECONDS - 1)
136
+ assert state.compute_mood(_snap()) == Mood.HAPPY
137
+
138
+
139
+ # ---- snapshot ----
140
+
141
+
142
+ def test_snapshot_contains_expected_keys():
143
+ state = TteokiState()
144
+ state.record_call()
145
+ with patch("chibi_mcp.state.read_snapshot", return_value=_snap()):
146
+ snap = state.snapshot()
147
+ assert set(snap.keys()) >= {"mood", "system", "counters", "timing"}
148
+ assert snap["counters"]["calls_total"] == 1
149
+ assert snap["counters"]["slice_interval"] == DEFAULT_SLICE_INTERVAL
@@ -0,0 +1,34 @@
1
+ """Tests for system_info.py — psutil wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chibi_mcp.system_info import SystemSnapshot, read_snapshot
6
+
7
+
8
+ def test_read_snapshot_returns_floats():
9
+ snap = read_snapshot(interval=0.0)
10
+ assert isinstance(snap, SystemSnapshot)
11
+ assert isinstance(snap.cpu_percent, float)
12
+ assert 0.0 <= snap.cpu_percent <= 100.0
13
+ assert 0.0 <= snap.ram_percent <= 100.0
14
+ # battery may be None on desktops
15
+ if snap.battery_percent is not None:
16
+ assert 0.0 <= snap.battery_percent <= 100.0
17
+ assert isinstance(snap.battery_plugged, bool)
18
+
19
+
20
+ def test_to_dict_rounds_and_handles_no_battery():
21
+ snap = SystemSnapshot(cpu_percent=12.345, ram_percent=67.891, battery_percent=None, battery_plugged=None)
22
+ d = snap.to_dict()
23
+ assert d == {
24
+ "cpu_percent": 12.3,
25
+ "ram_percent": 67.9,
26
+ "battery_percent": None,
27
+ "battery_plugged": None,
28
+ }
29
+
30
+
31
+ def test_to_dict_with_battery():
32
+ snap = SystemSnapshot(cpu_percent=10.0, ram_percent=20.0, battery_percent=55.555, battery_plugged=True)
33
+ assert snap.to_dict()["battery_percent"] == 55.6
34
+ assert snap.to_dict()["battery_plugged"] is True
@@ -0,0 +1,128 @@
1
+ """WebSocket E2E integration tests.
2
+
3
+ Spin up the real ws_server on an ephemeral port and assert that:
4
+ - connecting clients receive an initial `state` message,
5
+ - the periodic state push fires,
6
+ - manual `pet_say` and `slice` broadcasts reach the client.
7
+
8
+ These tests run in CI alongside the unit tests.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import json
16
+ import socket
17
+
18
+ import pytest
19
+ from websockets.asyncio.client import connect
20
+
21
+ from chibi_mcp.state import reset_state_for_tests
22
+ from chibi_mcp.ws_server import (
23
+ get_broadcaster,
24
+ reset_broadcaster_for_tests,
25
+ run_ws_server,
26
+ )
27
+
28
+
29
+ def _free_port() -> int:
30
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
31
+ s.bind(("127.0.0.1", 0))
32
+ return s.getsockname()[1]
33
+
34
+
35
+ @pytest.fixture
36
+ async def ws_url():
37
+ """Start a fresh ws_server on a free port for each test."""
38
+ reset_state_for_tests()
39
+ reset_broadcaster_for_tests()
40
+ port = _free_port()
41
+ server_task = asyncio.create_task(run_ws_server(host="127.0.0.1", port=port))
42
+ # Allow the server a tick to start listening
43
+ for _ in range(20):
44
+ await asyncio.sleep(0.05)
45
+ try:
46
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
47
+ s.settimeout(0.1)
48
+ s.connect(("127.0.0.1", port))
49
+ break
50
+ except OSError:
51
+ continue
52
+ yield f"ws://127.0.0.1:{port}"
53
+ server_task.cancel()
54
+ with contextlib.suppress(asyncio.CancelledError):
55
+ await server_task
56
+
57
+
58
+ async def test_initial_state_pushed_on_connect(ws_url):
59
+ async with connect(ws_url) as ws:
60
+ raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
61
+ msg = json.loads(raw)
62
+ assert msg["type"] == "state"
63
+ p = msg["payload"]
64
+ assert "mood" in p
65
+ assert "system" in p
66
+ assert "counters" in p
67
+ assert "timing" in p
68
+
69
+
70
+ async def test_pet_say_broadcasts_to_client(ws_url):
71
+ async with connect(ws_url) as ws:
72
+ # First message: state. Skip it.
73
+ await asyncio.wait_for(ws.recv(), timeout=2.0)
74
+
75
+ # Trigger a say broadcast
76
+ await get_broadcaster().broadcast({"type": "say", "text": "hello!"})
77
+ raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
78
+ msg = json.loads(raw)
79
+ assert msg["type"] == "say"
80
+ assert msg["text"] == "hello!"
81
+
82
+
83
+ async def test_slice_event_broadcasts_to_client(ws_url):
84
+ async with connect(ws_url) as ws:
85
+ await asyncio.wait_for(ws.recv(), timeout=2.0)
86
+
87
+ await get_broadcaster().broadcast({"type": "slice"})
88
+ raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
89
+ msg = json.loads(raw)
90
+ assert msg["type"] == "slice"
91
+
92
+
93
+ async def test_multiple_clients_all_receive_broadcast(ws_url):
94
+ async with connect(ws_url) as ws1, connect(ws_url) as ws2:
95
+ # Drain initial state on both
96
+ await asyncio.wait_for(ws1.recv(), timeout=2.0)
97
+ await asyncio.wait_for(ws2.recv(), timeout=2.0)
98
+
99
+ await get_broadcaster().broadcast({"type": "say", "text": "broadcast"})
100
+
101
+ m1 = json.loads(await asyncio.wait_for(ws1.recv(), timeout=2.0))
102
+ m2 = json.loads(await asyncio.wait_for(ws2.recv(), timeout=2.0))
103
+ assert m1["type"] == "say" and m2["type"] == "say"
104
+ assert m1["text"] == "broadcast" and m2["text"] == "broadcast"
105
+
106
+
107
+ async def test_state_pushes_periodically(ws_url):
108
+ """Verify the 2s push loop fires at least once during the test window."""
109
+ async with connect(ws_url) as ws:
110
+ # initial connect message
111
+ await asyncio.wait_for(ws.recv(), timeout=2.0)
112
+ # next message should be the periodic state (within ~3s)
113
+ raw = await asyncio.wait_for(ws.recv(), timeout=3.5)
114
+ msg = json.loads(raw)
115
+ assert msg["type"] == "state"
116
+
117
+
118
+ async def test_disconnect_unregisters_client(ws_url):
119
+ broadcaster = get_broadcaster()
120
+ async with connect(ws_url) as ws:
121
+ await asyncio.wait_for(ws.recv(), timeout=2.0)
122
+ # one client should be registered after connect+initial recv
123
+ # give the server a tick to register
124
+ await asyncio.sleep(0.05)
125
+ assert len(broadcaster._clients) == 1
126
+ # After context exit, ws is closed. Give server a tick to unregister.
127
+ await asyncio.sleep(0.2)
128
+ assert len(broadcaster._clients) == 0