nimbus-notify 1.0.0__py3-none-any.whl

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,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: nimbus-notify
3
+ Version: 1.0.0
4
+ Summary: Host broker that watches your AI coding sessions (Claude Code, Codex, Mistral Vibe) and pushes live status to a physical LED ring / e-ink device over a documented wire protocol
5
+ Project-URL: Homepage, https://github.com/ristllin/nimbus-notify
6
+ Project-URL: Repository, https://github.com/ristllin/nimbus-notify
7
+ Project-URL: Changelog, https://github.com/ristllin/nimbus-notify/blob/main/CHANGELOG.md
8
+ Author: Roy Darnell
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ble,claude-code,codex,hooks,led,mistral-vibe,notifier,serial
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: aiohttp>=3.9
22
+ Requires-Dist: bleak>=0.22
23
+ Requires-Dist: pyserial>=3.5
24
+ Requires-Dist: watchdog>=4.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # nimbus-notify
28
+
29
+ A Python host broker that watches your AI coding-agent sessions — **Claude
30
+ Code**, **Codex**, and **Mistral Vibe** — via lightweight hooks, and pushes
31
+ their live status (running / waiting for you / needs approval / done /
32
+ errored) to a physical display device over serial (USB-CDC) or Bluetooth LE,
33
+ using a small documented binary protocol ([nsn](docs/protocol.md)).
34
+
35
+ If you've got several agent sessions going in parallel across different
36
+ terminals and projects, nimbus-notify gives you one glanceable place — an LED
37
+ ring, an e-ink panel, whatever device you point it at — to see which ones
38
+ are still working and which ones are waiting on you.
39
+
40
+ ```
41
+ ┌────────────┐ hooks ┌───────────────┐ nsn wire protocol ┌──────────┐
42
+ │ Claude Code│ ─────────▶ │ │ (serial or BLE) │ status │
43
+ │ Codex │ ─────────▶ │ nsnotify- │ ─────────────────────▶│ device │
44
+ │ Mistral │ ─────────▶ │ broker │ │(your own)│
45
+ │ Vibe │ │ │ │ │
46
+ └────────────┘ └───────────────┘ └──────────┘
47
+ ```
48
+
49
+ ## Status
50
+
51
+ Beta. The core broker, all three harness adapters, and both transports are
52
+ implemented and tested (`python3 -m pytest`). This is a fresh split out of a
53
+ private monorepo into its own package — see [CHANGELOG.md](CHANGELOG.md).
54
+
55
+ ## Compatible devices
56
+
57
+ nimbus-notify speaks a documented, transport-agnostic wire protocol — see
58
+ [docs/protocol.md](docs/protocol.md). Any device that implements the
59
+ protocol's serial or BLE side can be driven by this broker; nothing here is
60
+ tied to a specific piece of hardware. If you build (or already have) a
61
+ microcontroller project with an LED strip, an e-ink panel, or any other
62
+ status display, point it at this broker.
63
+
64
+ ## Install
65
+
66
+ Not yet on PyPI — install from a clone:
67
+
68
+ ```bash
69
+ git clone https://github.com/ristllin/nimbus-notify.git
70
+ cd nimbus-notify
71
+ pip install -e .
72
+ ```
73
+
74
+ This installs two commands on your `PATH`:
75
+
76
+ - `nimbus-notify-broker` — the daemon that maintains session state and talks to
77
+ your device.
78
+ - `led-report` — the small CLI that harness hooks call to report events
79
+ into the broker (fire-and-forget; never blocks your agent).
80
+
81
+ PyPI publishing (`pip install nimbus-notify`) is a planned next step — for now,
82
+ the git-clone path above is the supported install method.
83
+
84
+ ## Quickstart
85
+
86
+ 1. Install the package (above).
87
+ 2. Wire up the harness(es) you use — see [Harnesses](#harnesses) below.
88
+ 3. Start the broker:
89
+ ```bash
90
+ nimbus-notify-broker
91
+ ```
92
+ 4. Start (or resume) an agent session in a wired-up harness. Its status
93
+ should now be reported to the broker, and forwarded to your device.
94
+
95
+ If you use Claude Code, the fastest path is the bundled slash commands —
96
+ see [Claude Code plugin](#claude-code-plugin) below.
97
+
98
+ ## Harnesses
99
+
100
+ nimbus-notify supports three AI coding harnesses today. Each harness reports
101
+ events (session start, a tool running, waiting on your approval, done,
102
+ errored, session end) by calling `led-report <harness> <verb>` from a hook.
103
+
104
+ ### Claude Code
105
+
106
+ Merge [`hooks/claude/settings.json`](hooks/claude/settings.json)'s `hooks`
107
+ block into your `~/.claude/settings.json`, preserving any hooks you already
108
+ have (append to each event's array rather than replacing it). It wires up
109
+ `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `Notification`, `Stop`,
110
+ `StopFailure`, and `SessionEnd`.
111
+
112
+ ### Codex
113
+
114
+ Merge [`hooks/codex/hooks.json`](hooks/codex/hooks.json) into
115
+ `~/.codex/hooks.json`, then enable hooks and the notify bridge in
116
+ `~/.codex/config.toml`:
117
+
118
+ ```toml
119
+ [features]
120
+ hooks = true
121
+
122
+ notify = ["led-report", "codex-notify"]
123
+ ```
124
+
125
+ ### Mistral Vibe
126
+
127
+ Vibe has no native session start/stop hook, so the broker also runs a
128
+ background watcher over `~/.vibe/logs/session/` to detect new sessions and
129
+ infer human-in-the-loop waits (if a tool call starts but doesn't finish
130
+ within a timeout, that's treated as "awaiting approval").
131
+
132
+ Enable experimental hooks in `~/.vibe/config.toml`:
133
+
134
+ ```toml
135
+ enable_experimental_hooks = true
136
+ ```
137
+
138
+ Then merge [`hooks/vibe/hooks.toml`](hooks/vibe/hooks.toml) into
139
+ `~/.vibe/hooks.toml` (requires Vibe v2.15.0+ for `before_tool` /
140
+ `after_tool` / `post_agent_turn`).
141
+
142
+ ### Claude Code plugin
143
+
144
+ This repo is also a Claude Code plugin (`.claude-plugin/plugin.json`). If
145
+ you install it as a plugin, two slash commands become available:
146
+
147
+ - `/nsnotify-setup` — installs the Python package, merges the Claude Code
148
+ hooks automatically, checks whether the broker is running, and prints
149
+ the manual steps for Codex/Vibe.
150
+ - `/nsnotify-status` — shows current session states without needing to look
151
+ at the device.
152
+
153
+ ## Transports
154
+
155
+ Pick a transport with `--transport`:
156
+
157
+ ```bash
158
+ nimbus-notify-broker --transport serial # default
159
+ nimbus-notify-broker --transport ble
160
+ nimbus-notify-broker --transport auto # serial if a device is plugged in at
161
+ # startup, else BLE
162
+ ```
163
+
164
+ - **Serial** auto-detects a likely USB-CDC port (Espressif native-USB VID
165
+ `0x303A`, or common USB-UART bridge chips), or pin one explicitly:
166
+ `nimbus-notify-broker --transport serial --port /dev/cu.usbmodem101`.
167
+ - **BLE** requires your device to be powered on, flashed with firmware that
168
+ advertises the nsn BLE service, and in range. Optionally pin a specific
169
+ device: `nimbus-notify-broker --transport ble --ble-address <address>`
170
+ (a CoreBluetooth UUID on macOS, a MAC address on Linux). Without
171
+ `--ble-address`, the broker scans for the nsn service UUID.
172
+
173
+ Transport selection happens once at startup — there's no live failover
174
+ between serial and BLE mid-session in this version.
175
+
176
+ ### Bonding the BLE link (one time)
177
+
178
+ Recent Nimbus firmware **secures the BLE link** (bonded + encrypted, LE Secure
179
+ Connections), so a device won't accept frames from an un-bonded computer — this
180
+ stops anyone in radio range from painting your ring. Bonding is **automatic**
181
+ (macOS "Just Works", no code to type) — but there are two things to know:
182
+
183
+ - **Nimbus does *not* appear in System Settings → Bluetooth.** That list only
184
+ shows recognized device types (keyboards, mice, audio). A custom BLE
185
+ peripheral is invisible there by design — don't look for it. Bonding happens
186
+ on-demand when the broker first touches the device, not by picking it in a
187
+ list.
188
+ - **Do the first bond with the broker in the foreground.** macOS only completes
189
+ a bond for a process running in your normal login session — a fully detached
190
+ process (e.g. `nohup … & disown`) is too detached and the bond silently fails.
191
+ So the *first* time, just run `nimbus-notify-broker --transport ble` in a normal
192
+ terminal and leave it up for a few seconds. Once bonded, the bond persists on
193
+ both the device (flash) and your Mac, and every session after that is
194
+ transparent — you can then run it backgrounded or as a service (below).
195
+
196
+ To un-bond: on the device, *Connectivity → Forget paired devices* (or the
197
+ `FORGETBONDS` console command); the Mac side clears on its own next connect.
198
+
199
+ > The firmware also carries a dormant MITM/passkey mode (it shows a 6-digit code
200
+ > on its e-ink screen). It's off by default because macOS won't surface the
201
+ > passkey-entry dialog for a broker-triggered pairing of a custom peripheral, so
202
+ > that pairing can't complete without a companion app.
203
+
204
+ ## Running persistently
205
+
206
+ For day-to-day use you'll want the broker running in the background
207
+ whenever you're coding, not started by hand each time.
208
+
209
+ ### macOS (launchd)
210
+
211
+ Create `~/Library/LaunchAgents/com.nimbus-notify.broker.plist`:
212
+
213
+ ```xml
214
+ <?xml version="1.0" encoding="UTF-8"?>
215
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
216
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
217
+ <plist version="1.0">
218
+ <dict>
219
+ <key>Label</key><string>com.nimbus-notify.broker</string>
220
+ <key>ProgramArguments</key>
221
+ <array>
222
+ <string>/usr/bin/env</string>
223
+ <string>nimbus-notify-broker</string>
224
+ <string>--transport</string>
225
+ <string>auto</string>
226
+ </array>
227
+ <key>RunAtLoad</key><true/>
228
+ <key>KeepAlive</key><true/>
229
+ <key>StandardOutPath</key><string>/tmp/nimbus-notify-broker.log</string>
230
+ <key>StandardErrorPath</key><string>/tmp/nimbus-notify-broker.log</string>
231
+ </dict>
232
+ </plist>
233
+ ```
234
+
235
+ Then:
236
+
237
+ ```bash
238
+ launchctl load ~/Library/LaunchAgents/com.nimbus-notify.broker.plist
239
+ ```
240
+
241
+ ### Linux (systemd, user service)
242
+
243
+ Create `~/.config/systemd/user/nimbus-notify-broker.service`:
244
+
245
+ ```ini
246
+ [Unit]
247
+ Description=nimbus-notify broker
248
+
249
+ [Service]
250
+ ExecStart=%h/.local/bin/nimbus-notify-broker --transport auto
251
+ Restart=on-failure
252
+
253
+ [Install]
254
+ WantedBy=default.target
255
+ ```
256
+
257
+ Then:
258
+
259
+ ```bash
260
+ systemctl --user daemon-reload
261
+ systemctl --user enable --now nimbus-notify-broker
262
+ ```
263
+
264
+ Adjust `ExecStart` to wherever `pip install -e .` put the entry point
265
+ (check with `which nimbus-notify-broker`).
266
+
267
+ ## The nsn wire protocol
268
+
269
+ A short summary — full details in [docs/protocol.md](docs/protocol.md).
270
+
271
+ ```
272
+ [SOF 0xAA] [LEN] [payload: LEN bytes] [CRC8-MAXIM]
273
+ ```
274
+
275
+ The payload starts with magic byte `0x4E`, then a sequence number, then up
276
+ to 16 fixed-size segment records (state, hue, animation, LED span) plus a
277
+ global brightness — enough for a device to render a full status frame from
278
+ a single self-contained packet, no persistent client-side state required.
279
+ The reference encoder/decoder is [`notify/broker/frame.py`](notify/broker/frame.py).
280
+
281
+ ## Repository layout
282
+
283
+ ```
284
+ notify/
285
+ broker/ frame.py (wire codec), segments.py, server.py, session.py
286
+ cli/ led_report.py — the hook-facing CLI entry point
287
+ harness/ base.py + claude.py, codex.py, vibe.py adapters
288
+ transport/ serial_tx.py, ble_tx.py
289
+ state.py shared State/Anim enums + default styling
290
+ tests/ pytest suite for the above
291
+ hooks/ drop-in hook configs per harness
292
+ commands/ Claude Code plugin slash commands
293
+ docs/ protocol.md
294
+ ```
295
+
296
+ ## Contributing
297
+
298
+ See [CONTRIBUTING.md](CONTRIBUTING.md), including the versioning
299
+ convention used for releases.
300
+
301
+ ## License
302
+
303
+ MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Roy Darnell.
@@ -0,0 +1,22 @@
1
+ notify/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ notify/state.py,sha256=4c_C2nqOlEo4b7dO3KeJxjLIYquX6eWV12RR_Z9oe9A,1894
3
+ notify/broker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ notify/broker/frame.py,sha256=Ki7_eo0RXEVKpSJ1kmlumu6ydswvnTMVNsb9nFkbyE4,2936
5
+ notify/broker/segments.py,sha256=JYG3qZyz0YyX7Ch1lOYxO4mQaF1sM29sO-6Badnxj78,3632
6
+ notify/broker/server.py,sha256=LgoNAyX5X9OQXJTsUi0xdoQGxBbdhsW885b0SjdIkpI,15605
7
+ notify/broker/session.py,sha256=DWMdAL9miB8AjxaSadnbblIw-38Qh3adSCPkzabs-t0,2530
8
+ notify/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ notify/cli/led_report.py,sha256=iIhC-qN5Rl1y47zH7oZtJynLP-z1J3xDrIb_pOF1diU,3237
10
+ notify/harness/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ notify/harness/base.py,sha256=HdSoevm20mKG3vpBTEfjUcORDd9lA-OCya_0eT9NSZA,578
12
+ notify/harness/claude.py,sha256=HVLwZmNXxZltpURvfoJJDOZ0KM8dA-7LqOrQKmfwgF0,1417
13
+ notify/harness/codex.py,sha256=3TvcLUpDqJ_0oSupQsix2t1rUTqMBAWTOol7xAlOlA0,1261
14
+ notify/harness/vibe.py,sha256=mYGfJwrf1Z33THaQDvLVz1_9mjD26R_0e2EHFVL8stk,6223
15
+ notify/transport/__init__.py,sha256=zy2Lp7gd4K2OCBmo6hv2B0tPgm84EyTXLxlaqWRn0pk,614
16
+ notify/transport/ble_tx.py,sha256=TQqBOasZCivVaCEhZopbQOXD40THe6i-1WM9lmwx_4U,16966
17
+ notify/transport/serial_tx.py,sha256=NnZ0zRZ812vRiHud-a3COBti7HPPF3_mbdTkJnLRvTY,3570
18
+ nimbus_notify-1.0.0.dist-info/METADATA,sha256=c5-hezJrJ4V1SN_B5p4Oc-F03UK3SEM7DVOA3kqppRg,11556
19
+ nimbus_notify-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
20
+ nimbus_notify-1.0.0.dist-info/entry_points.txt,sha256=7D2GeGf3dzMiSm0WjyUHH8MoJKhbFJA7eQvRzzQwSoY,107
21
+ nimbus_notify-1.0.0.dist-info/licenses/LICENSE,sha256=5gMUOlQupieheYGvmjavBGr7adTBxAJYaQ8Gj2Po5c8,1068
22
+ nimbus_notify-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ led-report = notify.cli.led_report:main
3
+ nimbus-notify-broker = notify.broker.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roy Darnell
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.
notify/__init__.py ADDED
File without changes
File without changes
notify/broker/frame.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ Phase 2 — Wire frame encoder / decoder.
3
+
4
+ Wire format (bytes on USB-CDC or BLE):
5
+ [SOF 0xAA] [LEN] [payload: LEN bytes] [CRC8]
6
+
7
+ Payload:
8
+ byte 0: MAGIC 0x4E
9
+ byte 1: sequence number (wraps at 255)
10
+ byte 2: segment count N
11
+ byte 3: global brightness (0–255)
12
+ bytes 4 .. 4+N*4-1: N segment records, 4 bytes each:
13
+ byte 0: state (notify.state.State)
14
+ byte 1: hue (0–254 HSV hue; 255 = white)
15
+ byte 2: anim (notify.state.Anim)
16
+ byte 3: span (LED count; 0 = auto-even)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ from notify.state import Anim, State, STATE_STYLE
23
+
24
+ FRAME_SOF = 0xAA
25
+ FRAME_MAGIC = 0x4E
26
+ MAX_SEGS = 16
27
+
28
+
29
+ @dataclass
30
+ class FrameSegment:
31
+ state: State
32
+ hue: int # 0–255
33
+ anim: Anim
34
+ span: int # 0 = auto
35
+
36
+ @classmethod
37
+ def from_state(cls, state: State, span: int = 0) -> "FrameSegment":
38
+ hue, anim = STATE_STYLE[state]
39
+ return cls(state=state, hue=hue, anim=anim, span=span)
40
+
41
+
42
+ def _crc8(data: bytes) -> int:
43
+ """CRC-8/MAXIM (polynomial 0x31, init 0x00, refin/refout True)."""
44
+ crc = 0
45
+ for byte in data:
46
+ crc ^= byte
47
+ for _ in range(8):
48
+ crc = (crc >> 1) ^ 0x8C if (crc & 0x01) else (crc >> 1)
49
+ return crc
50
+
51
+
52
+ def encode_frame(segments: list[FrameSegment], brightness: int, seq: int) -> bytes:
53
+ """Return a complete framed packet ready to write to the transport."""
54
+ n = min(len(segments), MAX_SEGS)
55
+ payload = bytes([
56
+ FRAME_MAGIC,
57
+ seq & 0xFF,
58
+ n,
59
+ brightness & 0xFF,
60
+ ])
61
+ for seg in segments[:n]:
62
+ payload += bytes([
63
+ int(seg.state) & 0xFF,
64
+ seg.hue & 0xFF,
65
+ int(seg.anim) & 0xFF,
66
+ seg.span & 0xFF,
67
+ ])
68
+ crc = _crc8(payload)
69
+ return bytes([FRAME_SOF, len(payload)]) + payload + bytes([crc])
70
+
71
+
72
+ @dataclass
73
+ class DecodedFrame:
74
+ seq: int
75
+ brightness: int
76
+ segments: list[FrameSegment]
77
+
78
+
79
+ def decode_frame(packet: bytes) -> DecodedFrame | None:
80
+ """Parse a raw packet (including SOF / LEN / CRC wrapper). Returns None on error."""
81
+ if len(packet) < 6:
82
+ return None
83
+ if packet[0] != FRAME_SOF:
84
+ return None
85
+ length = packet[1]
86
+ if len(packet) < 2 + length + 1:
87
+ return None
88
+ payload = packet[2 : 2 + length]
89
+ crc = packet[2 + length]
90
+ if _crc8(payload) != crc:
91
+ return None
92
+ if payload[0] != FRAME_MAGIC:
93
+ return None
94
+
95
+ n = payload[2]
96
+ brightness = payload[3]
97
+ segments: list[FrameSegment] = []
98
+ for i in range(n):
99
+ off = 4 + i * 4
100
+ if off + 4 > len(payload):
101
+ break
102
+ segments.append(FrameSegment(
103
+ state=State(payload[off]),
104
+ hue=payload[off + 1],
105
+ anim=Anim(payload[off + 2]),
106
+ span=payload[off + 3],
107
+ ))
108
+ return DecodedFrame(seq=payload[1], brightness=brightness, segments=segments)
@@ -0,0 +1,99 @@
1
+ """
2
+ Phase 2 — Segment allocator.
3
+
4
+ Rules:
5
+ - Each live session occupies exactly one segment index (0..MAX_SEGS-1).
6
+ - Segments are assigned in insertion order (lowest free index wins).
7
+ - A segment showing AWAITING_APPROVAL or WAITING_INPUT is never evicted to
8
+ make room for a new session while high-priority slots are full — the new
9
+ session is assigned the next available index instead.
10
+ - On free(), the index is returned to the pool immediately.
11
+ - active_segments() returns SessionRecords ordered by segment index, which
12
+ determines ring position.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from notify.broker.session import SessionRecord
17
+ from notify.state import State, STATE_PRIORITY
18
+
19
+ MAX_SEGS = 16
20
+
21
+
22
+ class SegmentAllocator:
23
+ def __init__(self, max_segs: int = MAX_SEGS) -> None:
24
+ self._max = max_segs
25
+ self._sessions: dict[str, SessionRecord] = {} # session_id → record
26
+ self._index: dict[str, int] = {} # session_id → segment index
27
+ self._used: set[int] = set()
28
+
29
+ # ------------------------------------------------------------------
30
+ # Mutation
31
+ # ------------------------------------------------------------------
32
+
33
+ def register(self, record: SessionRecord) -> int:
34
+ """Assign a segment to a new session. Returns the segment index, or -1 if full."""
35
+ if record.session_id in self._index:
36
+ return self._index[record.session_id]
37
+
38
+ idx = self._next_free()
39
+ if idx < 0:
40
+ return -1
41
+
42
+ self._sessions[record.session_id] = record
43
+ self._index[record.session_id] = idx
44
+ self._used.add(idx)
45
+ record.segment = idx
46
+ return idx
47
+
48
+ def update(self, record: SessionRecord) -> None:
49
+ """Update state for an already-registered session."""
50
+ sid = record.session_id
51
+ if sid not in self._sessions:
52
+ self.register(record)
53
+ return
54
+ self._sessions[sid].state = record.state
55
+ self._sessions[sid].last_event = record.last_event
56
+
57
+ def free(self, session_id: str) -> None:
58
+ """Release the segment held by session_id."""
59
+ idx = self._index.pop(session_id, None)
60
+ if idx is not None:
61
+ self._used.discard(idx)
62
+ self._sessions.pop(session_id, None)
63
+
64
+ def evict_stale(self) -> list[str]:
65
+ """Remove sessions that have exceeded their TTL. Returns evicted ids."""
66
+ stale = [sid for sid, rec in self._sessions.items() if rec.is_stale()]
67
+ for sid in stale:
68
+ self.free(sid)
69
+ return stale
70
+
71
+ # ------------------------------------------------------------------
72
+ # Query
73
+ # ------------------------------------------------------------------
74
+
75
+ def active_segments(self) -> list[SessionRecord]:
76
+ """Sessions ordered by segment index (ring position)."""
77
+ return sorted(self._sessions.values(), key=lambda r: self._index[r.session_id])
78
+
79
+ def highest_priority_state(self) -> State:
80
+ """State with the highest priority among all active sessions."""
81
+ if not self._sessions:
82
+ return State.Idle
83
+ return max(
84
+ (r.state for r in self._sessions.values()),
85
+ key=lambda s: STATE_PRIORITY[s],
86
+ )
87
+
88
+ def __len__(self) -> int:
89
+ return len(self._sessions)
90
+
91
+ # ------------------------------------------------------------------
92
+ # Internal
93
+ # ------------------------------------------------------------------
94
+
95
+ def _next_free(self) -> int:
96
+ for i in range(self._max):
97
+ if i not in self._used:
98
+ return i
99
+ return -1