pitwall-agents 0.1.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.

Potentially problematic release.


This version of pitwall-agents might be problematic. Click here for more details.

pitwall/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """pitwall: a pit wall for your coding agents."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Agent plug-ins: map an agent's own events to pitwall session events."""
@@ -0,0 +1,50 @@
1
+ """Claude Code hooks → pitwall session events.
2
+
3
+ Only ids, the folder and the terminal tab leave this module. Prompts, tool input
4
+ and output, and assistant messages are never read.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from typing import Mapping
10
+
11
+ AGENT = "claude-code"
12
+
13
+ _EVENT_STATE = {
14
+ "UserPromptSubmit": "green",
15
+ "PreToolUse": "green",
16
+ "PostToolUse": "green",
17
+ "PermissionRequest": "box",
18
+ "Stop": "done",
19
+ "StopFailure": "red",
20
+ "SessionStart": "done",
21
+ "SessionEnd": "end",
22
+ }
23
+ _NOTIFICATION_STATE = {"permission_prompt": "box", "elicitation_dialog": "box",
24
+ # Stop doesn't run when the user interrupts a turn; idle_prompt covers it
25
+ "idle_prompt": "done"}
26
+
27
+
28
+ def iterm_ref(env: Mapping[str, str]) -> dict | None:
29
+ raw = env.get("ITERM_SESSION_ID") or ""
30
+ ref = raw.split(":", 1)[-1].strip()
31
+ return {"kind": "iterm2", "ref": ref} if ref else None
32
+
33
+
34
+ def map_hook(payload: dict, env: Mapping[str, str]) -> dict | None:
35
+ if not isinstance(payload, dict):
36
+ return None
37
+ sid = payload.get("session_id")
38
+ event = payload.get("hook_event_name")
39
+ if not isinstance(sid, str) or not sid:
40
+ return None
41
+ if event == "Notification":
42
+ state = _NOTIFICATION_STATE.get(payload.get("notification_type"))
43
+ else:
44
+ state = _EVENT_STATE.get(event)
45
+ if state is None:
46
+ return None
47
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
48
+ label = os.path.basename(cwd.rstrip("/")) or "session"
49
+ return {"type": "session", "agent": AGENT, "id": sid, "state": state,
50
+ "label": label, "cwd": cwd, "terminal": iterm_ref(env)}
@@ -0,0 +1,119 @@
1
+ """Add or remove pitwall's hooks in Claude Code's settings.json, safely."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest",
13
+ "Notification", "Stop", "StopFailure", "SessionEnd")
14
+ _MATCHER_EVENTS = {"PreToolUse", "PostToolUse", "PermissionRequest"}
15
+
16
+
17
+ class SettingsError(Exception):
18
+ pass
19
+
20
+
21
+ @dataclass
22
+ class InstallResult:
23
+ changed: bool
24
+ backup: Path | None
25
+
26
+
27
+ def default_settings_path() -> Path:
28
+ return Path.home() / ".claude" / "settings.json"
29
+
30
+
31
+ def hook_command() -> str:
32
+ return shutil.which("pitwall-hook") or "pitwall-hook"
33
+
34
+
35
+ def _is_pitwall(cmd: str) -> bool:
36
+ return "pitwall-hook" in cmd or cmd.rstrip().endswith("pitwall hook")
37
+
38
+
39
+ def _load(path: Path) -> dict:
40
+ if not path.exists():
41
+ return {}
42
+ try:
43
+ data = json.loads(path.read_text() or "{}")
44
+ except ValueError as e:
45
+ raise SettingsError(f"{path} is not valid JSON ({e}); fix it first, nothing was changed") from e
46
+ if not isinstance(data, dict):
47
+ raise SettingsError(f"{path} must contain a JSON object; nothing was changed")
48
+ hooks = data.get("hooks", {})
49
+ if not isinstance(hooks, dict):
50
+ raise SettingsError(f'"hooks" in {path} must be an object; nothing was changed')
51
+ return data
52
+
53
+
54
+ def _strip(hooks: dict) -> dict:
55
+ """Return hooks with every pitwall command removed (and emptied entries dropped)."""
56
+ out = {}
57
+ for event, entries in hooks.items():
58
+ if not isinstance(entries, list):
59
+ out[event] = entries
60
+ continue
61
+ kept = []
62
+ for entry in entries:
63
+ if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
64
+ kept.append(entry)
65
+ continue
66
+ inner = [h for h in entry["hooks"]
67
+ if not (isinstance(h, dict) and _is_pitwall(str(h.get("command", ""))))]
68
+ if inner:
69
+ kept.append({**entry, "hooks": inner})
70
+ elif not entry["hooks"]:
71
+ kept.append(entry)
72
+ if kept:
73
+ out[event] = kept
74
+ return out
75
+
76
+
77
+ def _write(path: Path, original: str | None, data: dict) -> Path | None:
78
+ link = path
79
+ path = path.resolve() # keep a symlinked settings.json a symlink (dotfiles)
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
82
+ backup = None
83
+ if original is not None:
84
+ backup = link.with_name(f"{link.name}.pitwall-bak-{time.strftime('%Y%m%d-%H%M%S')}")
85
+ backup.write_text(original)
86
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".settings.", suffix=".tmp")
87
+ with os.fdopen(fd, "w") as f:
88
+ json.dump(data, f, indent=2)
89
+ f.write("\n")
90
+ os.chmod(tmp, mode)
91
+ os.replace(tmp, path)
92
+ return backup
93
+
94
+
95
+ def install(settings_path: Path, command: str) -> InstallResult:
96
+ data = _load(settings_path)
97
+ original = settings_path.read_text() if settings_path.exists() else None
98
+ hooks = _strip(data.get("hooks", {}))
99
+ for event in EVENTS:
100
+ entry: dict = {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
101
+ if event in _MATCHER_EVENTS:
102
+ entry = {"matcher": "", **entry}
103
+ hooks.setdefault(event, []).append(entry)
104
+ new = {**data, "hooks": hooks}
105
+ if new == data:
106
+ return InstallResult(False, None)
107
+ return InstallResult(True, _write(settings_path, original, new))
108
+
109
+
110
+ def uninstall(settings_path: Path) -> InstallResult:
111
+ if not settings_path.exists():
112
+ return InstallResult(False, None)
113
+ data = _load(settings_path)
114
+ original = settings_path.read_text()
115
+ hooks = _strip(data.get("hooks", {}))
116
+ new = {**data, "hooks": hooks} if hooks else {k: v for k, v in data.items() if k != "hooks"}
117
+ if new == data:
118
+ return InstallResult(False, None)
119
+ return InstallResult(True, _write(settings_path, original, new))
@@ -0,0 +1,239 @@
1
+ # pitwall: a pit wall for your coding agents, on the GitHub Universe badge.
2
+ # Shows every agent session's state and focuses its terminal tab on a button press.
3
+ import math
4
+
5
+ import links
6
+ import model
7
+
8
+ badge.mode(HIRES | VSYNC)
9
+
10
+ try:
11
+ import missing_details
12
+ FONT = missing_details.mona_sans()
13
+ except Exception:
14
+ FONT = None
15
+
16
+ UID = badge.uid if isinstance(badge.uid, str) else "".join("%02x" % b for b in badge.uid)
17
+ CAPS = {"display": [320, 240], "color": True,
18
+ "buttons": ["up", "down", "left", "right", "select"], "keys": 0, "led": True}
19
+
20
+ bg = color.rgb(12, 14, 20)
21
+ panel = color.rgb(30, 34, 46)
22
+ white = color.rgb(238, 240, 246)
23
+ dim = color.rgb(238, 240, 246, 110)
24
+ faint = color.rgb(238, 240, 246, 50)
25
+ sub = color.rgb(238, 240, 246, 165)
26
+ amber = color.rgb(255, 196, 40)
27
+ red = color.rgb(240, 70, 70)
28
+ green = color.rgb(80, 210, 110)
29
+ black = color.rgb(20, 20, 20)
30
+
31
+ STATE_PEN = {"box": amber, "red": red, "green": green}
32
+ STATE_WORD = {"red": "failed", "done": "done", "green": "working"}
33
+
34
+ ROW_TOP, ROW_H, ROWS = 27, 31, 6
35
+ FOOT_Y = 215
36
+
37
+ view = model.View()
38
+ usb = links.Usb()
39
+ try:
40
+ ble = links.Ble(UID, {"type": "hello", "device": "badger2350", "uid": UID, "transport": "ble", "caps": CAPS})
41
+ except Exception as e:
42
+ print("pitwall: bluetooth unavailable", e)
43
+ ble = None
44
+
45
+ last_rx = {"usb": None, "ble": None}
46
+ last_hello_ms = -10000
47
+
48
+
49
+ def hello(transport):
50
+ return {"type": "hello", "device": "badger2350", "uid": UID, "transport": transport, "caps": CAPS}
51
+
52
+
53
+ def active_transport(now):
54
+ if last_rx["usb"] is not None and now - last_rx["usb"] <= model.STALE_MS:
55
+ return "usb"
56
+ if ble is not None and ble.authed:
57
+ return "ble"
58
+ return None
59
+
60
+
61
+ def send(msg, now):
62
+ t = active_transport(now)
63
+ if t == "usb":
64
+ usb.send(msg)
65
+ elif t == "ble":
66
+ ble.send(msg)
67
+
68
+
69
+ def handle(msg, transport, now):
70
+ kind = msg.get("type")
71
+ if kind == "hello":
72
+ if transport == "usb":
73
+ usb.send(hello("usb"))
74
+ return
75
+ if kind == "pair_key" and transport == "usb":
76
+ # USB is a local cable: the helper hands over its Bluetooth key here
77
+ try:
78
+ host, key = msg["host"], bytes.fromhex(msg["key"])
79
+ except Exception:
80
+ return
81
+ if len(key) == 32:
82
+ if ble is not None:
83
+ ble.add_key(host, key)
84
+ else:
85
+ keys = links.load_keys()
86
+ keys[host] = key
87
+ links.save_keys(keys)
88
+ usb.send({"type": "paired", "host": host})
89
+ return
90
+ if kind in ("board", "alert"):
91
+ last_rx[transport] = now
92
+ # while USB is live, ignore a stale BLE board (the helper only talks on one anyway)
93
+ if transport == "ble" and active_transport(now) == "usb":
94
+ return
95
+ view.on_message(msg, now)
96
+
97
+
98
+ def text(s, x, y, size, pen):
99
+ screen.pen = pen
100
+ screen.text(s, x, y, size)
101
+
102
+
103
+ def text_w(s, size):
104
+ return screen.measure_text(s, size)[0]
105
+
106
+
107
+ def chip(state, x, y, s, flash):
108
+ if state == "done":
109
+ half = s / 2
110
+ for i in range(2):
111
+ for j in range(2):
112
+ screen.pen = white if (i + j) % 2 == 0 else black
113
+ screen.shape(shape.rectangle(x + i * half, y + j * half, half, half))
114
+ return
115
+ pen = STATE_PEN.get(state, dim)
116
+ if state == "box" and flash:
117
+ pen = white
118
+ screen.pen = pen
119
+ screen.shape(shape.rounded_rectangle(x, y, s, s, 3))
120
+
121
+
122
+ def draw_header(now):
123
+ screen.pen = panel
124
+ screen.shape(shape.rectangle(0, 0, 320, 24))
125
+ x = 8
126
+ for state in ("box", "red", "done", "green"):
127
+ chip(state, x, 6, 12, False)
128
+ n = str(view.counts.get(state, 0))
129
+ text(n, x + 16, 4, 14, white)
130
+ x += 26 + text_w(n, 14)
131
+ t = active_transport(now)
132
+ link = "USB" if t == "usb" else ("BT" if t == "ble" else "--")
133
+ try:
134
+ level = int(badge.battery_level())
135
+ except Exception:
136
+ level = None
137
+ right = link + (" %d%%" % level if level is not None else "")
138
+ if len(view.sessions) > ROWS:
139
+ right = "%d/%d " % (view.cursor + 1, len(view.sessions)) + right
140
+ text(right, 312 - text_w(right, 12), 6, 12, dim if t else red)
141
+
142
+
143
+ def fit(s, size, max_w):
144
+ """Cut text to fit max_w pixels, with an ellipsis."""
145
+ s = str(s)
146
+ if text_w(s, size) <= max_w:
147
+ return s
148
+ while s and text_w(s + "…", size) > max_w:
149
+ s = s[:-1]
150
+ return s + "…"
151
+
152
+
153
+ def draw_rows(now, stale):
154
+ rows = view.rows(ROWS)
155
+ if not rows:
156
+ msg = "No agent sessions yet" if not stale else "Waiting for pit wall…"
157
+ text(msg, 160 - text_w(msg, 16) / 2, 105, 16, dim)
158
+ return
159
+ flash = view.alerting(now) and (now // 250) % 2 == 0
160
+ for idx, (i, s, selected) in enumerate(rows):
161
+ y = ROW_TOP + idx * ROW_H
162
+ if selected:
163
+ screen.pen = panel
164
+ screen.shape(shape.rounded_rectangle(4, y, 312, ROW_H - 2, 6))
165
+ state = s.get("state", "")
166
+ chip(state, 12, y + 8, 13, flash and s.get("id") == view.alert_id)
167
+ if state == "box":
168
+ right, rpen = model.fmt_age(s.get("age_s", 0)), amber
169
+ else:
170
+ right, rpen = STATE_WORD.get(state, ""), dim
171
+ rw = text_w(right, 12)
172
+ text(right, 308 - rw, y + 9, 12, rpen if not stale else faint)
173
+ max_w = 308 - rw - 10 - 34
174
+ live = s.get("focusable", True) and not stale
175
+ title, folder = s.get("title"), str(s.get("label", ""))
176
+ if title and title.strip().lower() != folder.strip().lower():
177
+ text(fit(title, 14, max_w), 34, y + 2, 14, white if live else dim)
178
+ text(fit(folder, 11, max_w), 34, y + 17, 11, sub if live else faint)
179
+ else:
180
+ text(fit(title or folder, 14, max_w), 34, y + 8, 14, white if live else dim)
181
+
182
+
183
+ def draw_footer(stale):
184
+ screen.pen = red if stale else panel
185
+ screen.shape(shape.rectangle(0, FOOT_Y + 2, 320, 240 - FOOT_Y - 2))
186
+ if stale:
187
+ msg = "No connection to pit wall"
188
+ text(msg, 160 - text_w(msg, 12) / 2, FOOT_Y + 5, 12, white)
189
+ return
190
+ x = 8
191
+ for key, what in (("SELECT", "focus"), ("RIGHT", "next box box"), ("LEFT", "top")):
192
+ text(key, x, FOOT_Y + 5, 12, amber)
193
+ x += text_w(key, 12) + 4
194
+ text(what, x, FOOT_Y + 5, 12, white)
195
+ x += text_w(what, 12) + 12
196
+
197
+
198
+ def update():
199
+ global last_hello_ms
200
+ now = badge.ticks
201
+ for m in usb.read():
202
+ handle(m, "usb", now)
203
+ if ble is not None:
204
+ for m in ble.read():
205
+ handle(m, "ble", now)
206
+
207
+ if badge.pressed(BUTTON_UP):
208
+ view.move(-1)
209
+ elif badge.pressed(BUTTON_DOWN):
210
+ view.move(1)
211
+ elif badge.pressed(BUTTON_LEFT):
212
+ view.move(-len(view.sessions))
213
+ elif badge.pressed(BUTTON_SELECT):
214
+ sid = view.selected_id()
215
+ if sid:
216
+ send({"type": "focus", "id": sid}, now)
217
+ elif badge.pressed(BUTTON_RIGHT):
218
+ send({"type": "focus_next_waiting"}, now)
219
+
220
+ stale = view.stale(now)
221
+ if stale and now - last_hello_ms > 5000:
222
+ usb.send(hello("usb")) # the helper may have missed our first hello
223
+ last_hello_ms = now
224
+
225
+ try:
226
+ badge.caselights((0.5 + 0.5 * math.sin(now / 120)) if view.alerting(now) else 0)
227
+ except Exception:
228
+ pass
229
+
230
+ if FONT is not None:
231
+ screen.font = FONT
232
+ screen.pen = bg
233
+ screen.clear()
234
+ draw_header(now)
235
+ draw_rows(now, stale)
236
+ draw_footer(stale)
237
+
238
+
239
+ run(update)
Binary file
@@ -0,0 +1,179 @@
1
+ # pitwall badge: USB serial and Bluetooth LE links.
2
+ import json
3
+ import os
4
+ import select
5
+ import sys
6
+ import time
7
+
8
+ import bluetooth
9
+ import model
10
+ import seal
11
+
12
+ KEYS_FILE = "/state/pitwall_keys.json"
13
+
14
+ _SERVICE = bluetooth.UUID("6f1c0001-9b2a-4c55-8f3e-70697477616c")
15
+ _RX = (bluetooth.UUID("6f1c0002-9b2a-4c55-8f3e-70697477616c"), 0x0008 | 0x0004) # write, write-no-response
16
+ _TX = (bluetooth.UUID("6f1c0003-9b2a-4c55-8f3e-70697477616c"), 0x0010) # notify
17
+
18
+ _IRQ_CENTRAL_CONNECT = 1
19
+ _IRQ_CENTRAL_DISCONNECT = 2
20
+ _IRQ_GATTS_WRITE = 3
21
+ _IRQ_MTU_EXCHANGED = 21
22
+
23
+
24
+ def _line(msg):
25
+ m = dict(msg)
26
+ m["v"] = 1
27
+ return json.dumps(m).encode() + b"\n"
28
+
29
+
30
+ class Usb:
31
+ transport = "usb"
32
+
33
+ def __init__(self):
34
+ self.poll = select.poll()
35
+ self.poll.register(sys.stdin, select.POLLIN)
36
+ self.dec = model.Decoder()
37
+ self.stdin = getattr(sys.stdin, "buffer", sys.stdin)
38
+
39
+ def read(self):
40
+ data = b""
41
+ while len(data) < 2048 and self.poll.poll(0):
42
+ c = self.stdin.read(1)
43
+ if not c:
44
+ break
45
+ data += c if isinstance(c, bytes) else c.encode()
46
+ return self.dec.feed(data) if data else []
47
+
48
+ def send(self, msg):
49
+ try:
50
+ sys.stdout.write(_line(msg).decode())
51
+ except Exception:
52
+ pass
53
+
54
+
55
+ def load_keys():
56
+ try:
57
+ with open(KEYS_FILE) as f:
58
+ return {h: bytes.fromhex(k) for h, k in json.load(f).items()}
59
+ except Exception:
60
+ return {}
61
+
62
+
63
+ def save_keys(keys):
64
+ try:
65
+ with open(KEYS_FILE, "w") as f:
66
+ json.dump({h: k.hex() for h, k in keys.items()}, f)
67
+ except Exception as e:
68
+ print("pitwall: could not save keys", e)
69
+
70
+
71
+ def _adv(adv_type, value):
72
+ return bytes((len(value) + 1, adv_type)) + value
73
+
74
+
75
+ class Ble:
76
+ transport = "ble"
77
+
78
+ def __init__(self, uid, hello):
79
+ self.uid = uid
80
+ self.hello = hello
81
+ self.name = "pitwall-" + uid[:8]
82
+ self.keys = load_keys()
83
+ self.ble = bluetooth.BLE()
84
+ self.ble.active(True)
85
+ try:
86
+ self.ble.config(gap_name=self.name, mtu=247)
87
+ except Exception:
88
+ self.ble.config(gap_name=self.name)
89
+ self.ble.irq(self._irq)
90
+ ((self.h_tx, self.h_rx),) = self.ble.gatts_register_services(((_SERVICE, (_TX, _RX)),))
91
+ self.ble.gatts_set_buffer(self.h_rx, 512, True)
92
+ self.conn = None
93
+ self.mtu = 23
94
+ self.inbox = b""
95
+ self._reset()
96
+ self._advertise()
97
+
98
+ def _reset(self):
99
+ self.dec = model.Decoder()
100
+ self.auth = seal.DeviceAuth(self.uid, self.keys, os.urandom)
101
+ self.sealer = None
102
+ self.opener = None
103
+
104
+ def add_key(self, host, key):
105
+ """Called when the helper sends this Mac's key over USB."""
106
+ if self.keys.get(host) != key:
107
+ self.keys[host] = key
108
+ save_keys(self.keys)
109
+
110
+ @property
111
+ def authed(self):
112
+ return self.sealer is not None
113
+
114
+ def _advertise(self):
115
+ flags = _adv(0x01, b"\x06")
116
+ uuid = _adv(0x07, bytes(reversed(bytes.fromhex("6f1c00019b2a4c558f3e70697477616c"))))
117
+ name = _adv(0x09, self.name.encode())
118
+ try:
119
+ self.ble.gap_advertise(200_000, adv_data=flags + uuid, resp_data=name)
120
+ except Exception as e:
121
+ print("pitwall: advertise failed", e)
122
+
123
+ def _irq(self, event, data):
124
+ if event == _IRQ_CENTRAL_CONNECT:
125
+ self.conn = data[0]
126
+ self._reset()
127
+ elif event == _IRQ_CENTRAL_DISCONNECT:
128
+ self.conn = None
129
+ self._reset()
130
+ self._advertise()
131
+ elif event == _IRQ_GATTS_WRITE:
132
+ self.inbox += self.ble.gatts_read(self.h_rx)
133
+ elif event == _IRQ_MTU_EXCHANGED:
134
+ self.mtu = data[1]
135
+
136
+ def _notify(self, data):
137
+ if self.conn is None:
138
+ return
139
+ size = max(20, self.mtu - 3)
140
+ for i in range(0, len(data), size):
141
+ for _ in range(20):
142
+ try:
143
+ self.ble.gatts_notify(self.conn, self.h_tx, data[i:i + size])
144
+ break
145
+ except OSError:
146
+ time.sleep_ms(5)
147
+
148
+ def _raw(self, msg):
149
+ self._notify(_line(msg))
150
+
151
+ def send(self, msg):
152
+ if self.sealer is not None:
153
+ self._notify(_line(self.sealer.seal(_line(msg).rstrip(b"\n"))))
154
+
155
+ def read(self):
156
+ """Returns app messages (only after auth). Handles the handshake itself."""
157
+ if not self.inbox:
158
+ return []
159
+ data, self.inbox = self.inbox, b""
160
+ out = []
161
+ for m in self.dec.feed(data):
162
+ kind = m.get("type")
163
+ if kind == "sealed":
164
+ if self.opener is not None:
165
+ line = self.opener.open(m)
166
+ if line is not None:
167
+ out += model.Decoder().feed(line + b"\n")
168
+ elif kind == "hello" and not self.authed:
169
+ self._raw(self.hello)
170
+ self._raw(self.auth.challenge())
171
+ elif kind == "auth" and not self.authed:
172
+ dn = self.auth.nonce
173
+ reply, host, skey = self.auth.handle(m)
174
+ self._raw(reply)
175
+ if host:
176
+ hn = bytes.fromhex(m["hn"])
177
+ self.sealer = seal.Sealer(skey, dn + hn, "d2h")
178
+ self.opener = seal.Opener(skey, dn + hn, "h2d")
179
+ return out
@@ -0,0 +1,98 @@
1
+ # pitwall badge: pure state (no hardware). Runs on MicroPython and CPython (tests).
2
+ import json
3
+
4
+ STALE_MS = 15000
5
+ ALERT_MS = 3000
6
+
7
+
8
+ class Decoder:
9
+ """Bytes in, protocol messages out. Skips anything that isn't a v1 JSON object."""
10
+
11
+ def __init__(self):
12
+ self.buf = b""
13
+
14
+ def feed(self, data):
15
+ self.buf += data
16
+ out = []
17
+ while b"\n" in self.buf:
18
+ line, self.buf = self.buf.split(b"\n", 1)
19
+ line = line.strip()
20
+ if not line:
21
+ continue
22
+ try:
23
+ msg = json.loads(line.decode("utf-8"))
24
+ except Exception:
25
+ continue
26
+ if isinstance(msg, dict) and msg.get("v") == 1:
27
+ out.append(msg)
28
+ if len(self.buf) > 16384:
29
+ self.buf = b""
30
+ return out
31
+
32
+
33
+ def fmt_age(s):
34
+ s = max(0, int(s))
35
+ if s < 60:
36
+ return "%ds" % s
37
+ if s < 3600:
38
+ return "%dm" % (s // 60)
39
+ return "%dh%02d" % (s // 3600, (s % 3600) // 60)
40
+
41
+
42
+ class View:
43
+ def __init__(self):
44
+ self.sessions = []
45
+ self.counts = {"box": 0, "red": 0, "done": 0, "green": 0}
46
+ self.cursor = 0
47
+ self.top = 0
48
+ self.last_board_ms = None
49
+ self.alert_ms = None
50
+ self.alert_id = None
51
+
52
+ def on_message(self, msg, now_ms):
53
+ kind = msg.get("type")
54
+ if kind == "board":
55
+ keep = self.selected_id()
56
+ self.sessions = msg.get("sessions") or []
57
+ self.counts = msg.get("counts") or self.counts
58
+ self.last_board_ms = now_ms
59
+ ids = [s.get("id") for s in self.sessions]
60
+ if keep in ids:
61
+ self.cursor = ids.index(keep)
62
+ self._clamp()
63
+ elif kind == "alert":
64
+ self.alert_ms = now_ms
65
+ self.alert_id = msg.get("id")
66
+
67
+ def _clamp(self):
68
+ n = len(self.sessions)
69
+ self.cursor = 0 if n == 0 else max(0, min(self.cursor, n - 1))
70
+
71
+ def move(self, delta):
72
+ self.cursor += delta
73
+ self._clamp()
74
+
75
+ def selected_id(self):
76
+ if not self.sessions:
77
+ return None
78
+ return self.sessions[self.cursor].get("id")
79
+
80
+ def rows(self, max_rows=8):
81
+ n = len(self.sessions)
82
+ if n == 0:
83
+ return []
84
+ if self.cursor < self.top:
85
+ self.top = self.cursor
86
+ elif self.cursor >= self.top + max_rows:
87
+ self.top = self.cursor - max_rows + 1
88
+ self.top = max(0, min(self.top, max(0, n - max_rows)))
89
+ out = []
90
+ for i in range(self.top, min(n, self.top + max_rows)):
91
+ out.append((i, self.sessions[i], i == self.cursor))
92
+ return out
93
+
94
+ def stale(self, now_ms):
95
+ return self.last_board_ms is None or now_ms - self.last_board_ms > STALE_MS
96
+
97
+ def alerting(self, now_ms):
98
+ return self.alert_ms is not None and now_ms - self.alert_ms < ALERT_MS