iterm-dash 0.1.1__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.
- iterm_dash/__init__.py +0 -0
- iterm_dash/backend.py +395 -0
- iterm_dash/cli.py +313 -0
- iterm_dash/demo.py +554 -0
- iterm_dash/hub.py +239 -0
- iterm_dash/palette.py +77 -0
- iterm_dash/pump.py +45 -0
- iterm_dash/registry.py +79 -0
- iterm_dash/render.py +86 -0
- iterm_dash/server.py +176 -0
- iterm_dash/smoke.py +72 -0
- iterm_dash/state.py +94 -0
- iterm_dash/status.py +74 -0
- iterm_dash/surface.py +116 -0
- iterm_dash/web/app.js +760 -0
- iterm_dash/web/clicks.js +26 -0
- iterm_dash/web/format.js +7 -0
- iterm_dash/web/index.html +74 -0
- iterm_dash/web/layout.js +14 -0
- iterm_dash/web/nav.js +36 -0
- iterm_dash/web/rows.js +23 -0
- iterm_dash/web/style.css +172 -0
- iterm_dash/web.py +65 -0
- iterm_dash-0.1.1.dist-info/METADATA +270 -0
- iterm_dash-0.1.1.dist-info/RECORD +28 -0
- iterm_dash-0.1.1.dist-info/WHEEL +4 -0
- iterm_dash-0.1.1.dist-info/entry_points.txt +2 -0
- iterm_dash-0.1.1.dist-info/licenses/LICENSE +21 -0
iterm_dash/__init__.py
ADDED
|
File without changes
|
iterm_dash/backend.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Live iTerm2 backend: discovery, throttled previews, native status and focus.
|
|
2
|
+
|
|
3
|
+
Read-only toward the user's sessions. The only calls that change anything are
|
|
4
|
+
the two focus calls (``session.async_activate`` and ``app.async_activate``).
|
|
5
|
+
Logs carry ids, counts and timings only; exceptions are logged by type name,
|
|
6
|
+
because their messages can carry screen text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import iterm2
|
|
18
|
+
from iterm2 import api_pb2
|
|
19
|
+
from iterm2 import rpc as iterm2_rpc
|
|
20
|
+
from iterm2.screen import LineContents
|
|
21
|
+
|
|
22
|
+
from iterm_dash.hub import ScreenFrame
|
|
23
|
+
from iterm_dash.palette import DEFAULT_PALETTE, Palette
|
|
24
|
+
from iterm_dash.pump import LatestOnlyPump
|
|
25
|
+
from iterm_dash.registry import SessionInfo, list_sessions
|
|
26
|
+
from iterm_dash.render import Row, line_row
|
|
27
|
+
from iterm_dash.status import StatusTracker, StatusView, classify
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
_RETRY_SECONDS = 1.0
|
|
31
|
+
_DEBOUNCE_SECONDS = 0.05
|
|
32
|
+
_DISCOVERY_INTERVAL = 10.0 # a periodic pass catches title changes and missed layout events
|
|
33
|
+
_BUFFER_OK = api_pb2.GetBufferResponse.Status.Value("OK")
|
|
34
|
+
# Passes that may follow a change iTerm2 didn't announce to the App, so they reload its hierarchy first.
|
|
35
|
+
# Discovery triggers: start, exclude, layout, new, terminated, status-flip, size-change, periodic.
|
|
36
|
+
_REFRESH_TRIGGERS = frozenset({"size-change", "periodic"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _LineCache:
|
|
41
|
+
"""Per-session render cache: the last frame's rows keyed by each line's raw proto bytes.
|
|
42
|
+
|
|
43
|
+
Keyed by content, not position, so a scrolled screen reuses the rows that moved.
|
|
44
|
+
Rows may be shared between lines and frames; they are never mutated downstream.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
palette: Palette
|
|
48
|
+
rows: dict[bytes, Row]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _get_buffer(session) -> api_pb2.GetBufferResponse:
|
|
52
|
+
"""The raw styled screen proto, fetched the way Session.async_get_screen_contents does."""
|
|
53
|
+
result = await iterm2_rpc.async_get_screen_contents(session.connection, session.session_id, None, True)
|
|
54
|
+
response = result.get_buffer_response
|
|
55
|
+
if response.status != _BUFFER_OK:
|
|
56
|
+
raise iterm2.RPCException(api_pb2.GetBufferResponse.Status.Name(response.status))
|
|
57
|
+
return response
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def read_status(connection, session_id: str) -> dict | None:
|
|
61
|
+
"""iTerm2's native status dict for a session, or None when the read itself failed.
|
|
62
|
+
|
|
63
|
+
A failed read (e.g. a timeout) carries no information. A session that isn't running
|
|
64
|
+
Claude still reads successfully: its dict has ``status`` None, or it is ``{}`` when
|
|
65
|
+
iTerm2 answered with something other than a dict.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
result = await iterm2.async_invoke_function(
|
|
69
|
+
connection, f'iterm2.get_session_status(session_id: "{session_id}")', timeout=2
|
|
70
|
+
)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
_log.debug("status read failed for %s: %s", session_id, type(exc).__name__)
|
|
73
|
+
return None
|
|
74
|
+
return result if isinstance(result, dict) else {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def has_claude_status(raw: dict | None) -> bool:
|
|
78
|
+
return raw is not None and raw.get("status") is not None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _keep_running(name: str, body: Callable[[], Awaitable[None]]) -> None:
|
|
82
|
+
"""Run ``body`` and run it again shortly after it fails, until cancelled."""
|
|
83
|
+
while True:
|
|
84
|
+
try:
|
|
85
|
+
await body()
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
raise
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
task = asyncio.current_task()
|
|
90
|
+
if task is not None and task.cancelling():
|
|
91
|
+
# A cleanup step (e.g. unsubscribing on a dead socket) replaced
|
|
92
|
+
# our cancellation with its own error; stay cancelled.
|
|
93
|
+
raise asyncio.CancelledError from None
|
|
94
|
+
_log.warning("%s failed: %s", name, type(exc).__name__)
|
|
95
|
+
await asyncio.sleep(_RETRY_SECONDS)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ItermBackend:
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
connection,
|
|
102
|
+
hub,
|
|
103
|
+
*,
|
|
104
|
+
preview_interval: float = 0.25,
|
|
105
|
+
status_interval: float = 1.0,
|
|
106
|
+
discovery_interval: float = _DISCOVERY_INTERVAL,
|
|
107
|
+
app=None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._conn = connection
|
|
110
|
+
self._hub = hub
|
|
111
|
+
self._preview_interval = preview_interval
|
|
112
|
+
self._status_interval = status_interval
|
|
113
|
+
self._discovery_interval = discovery_interval
|
|
114
|
+
self._app = app
|
|
115
|
+
self._excluded: set[str] = set()
|
|
116
|
+
self._infos: dict[str, SessionInfo] = {} # in cell order: first seen first
|
|
117
|
+
self._watchers: dict[str, asyncio.Task] = {}
|
|
118
|
+
self._pumps: dict[str, LatestOnlyPump] = {}
|
|
119
|
+
self._palettes: dict[str, Palette] = {}
|
|
120
|
+
self._palette_gen = 0
|
|
121
|
+
self._line_cache: dict[str, _LineCache] = {}
|
|
122
|
+
self._status_locks: dict[str, asyncio.Lock] = {}
|
|
123
|
+
self._claude_seen: dict[str, bool] = {}
|
|
124
|
+
self._last_view: dict[str, StatusView] = {} # the view last pushed to the hub
|
|
125
|
+
self._size_flagged: dict[str, tuple[int, int]] = {} # (buffer rows, grid rows) already rediscovered
|
|
126
|
+
self._tracker = StatusTracker()
|
|
127
|
+
self._rediscover = asyncio.Event()
|
|
128
|
+
self._triggers: set[str] = set() # why the next discovery pass runs
|
|
129
|
+
self._children: set[asyncio.Task] = set()
|
|
130
|
+
|
|
131
|
+
# -- public API -----------------------------------------------------------
|
|
132
|
+
def exclude(self, session_id: str) -> None:
|
|
133
|
+
"""Never watch this session (the dashboard's own tab)."""
|
|
134
|
+
self._excluded.add(session_id)
|
|
135
|
+
self._request_discovery("exclude")
|
|
136
|
+
|
|
137
|
+
async def run(self) -> None:
|
|
138
|
+
"""Watch iTerm2 until the connection closes, then stop every child task."""
|
|
139
|
+
closed = asyncio.create_task(self._conn.websocket.wait_closed())
|
|
140
|
+
main = asyncio.create_task(self._main())
|
|
141
|
+
try:
|
|
142
|
+
done, _ = await asyncio.wait({main, closed}, return_when=asyncio.FIRST_COMPLETED)
|
|
143
|
+
if closed in done:
|
|
144
|
+
_log.info("iTerm2 connection closed; backend stopping")
|
|
145
|
+
elif not main.cancelled() and main.exception() is not None:
|
|
146
|
+
_log.error("backend failed: %s", type(main.exception()).__name__)
|
|
147
|
+
finally:
|
|
148
|
+
tasks = [main, closed, *self._children, *self._watchers.values()]
|
|
149
|
+
for t in tasks:
|
|
150
|
+
t.cancel()
|
|
151
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
152
|
+
self._children.clear()
|
|
153
|
+
self._watchers.clear()
|
|
154
|
+
self._pumps.clear()
|
|
155
|
+
_log.info("backend stopped")
|
|
156
|
+
|
|
157
|
+
async def focus(self, session_id: str) -> None:
|
|
158
|
+
app = self._app
|
|
159
|
+
session = app.get_session_by_id(session_id) if app is not None else None
|
|
160
|
+
if session is None:
|
|
161
|
+
_log.info("focus: session %s not found", session_id)
|
|
162
|
+
return
|
|
163
|
+
start = time.perf_counter()
|
|
164
|
+
await session.async_activate(select_tab=True, order_window_front=True)
|
|
165
|
+
await app.async_activate(raise_all_windows=False, ignoring_other_apps=True)
|
|
166
|
+
_log.info("focus %s took %.0f ms", session_id, (time.perf_counter() - start) * 1000)
|
|
167
|
+
|
|
168
|
+
async def fetch_frame(self, session) -> ScreenFrame | None:
|
|
169
|
+
"""A styled snapshot of the session, or None when it can't be read (e.g. gone)."""
|
|
170
|
+
sid = session.session_id
|
|
171
|
+
try:
|
|
172
|
+
buffer = await _get_buffer(session)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
_log.debug("screen fetch failed for %s: %s", sid, type(exc).__name__)
|
|
175
|
+
return None
|
|
176
|
+
ts = time.time()
|
|
177
|
+
size = session.grid_size
|
|
178
|
+
if size is None:
|
|
179
|
+
return None
|
|
180
|
+
# grid_size is only refreshed by layout events; the buffer always has the screen's real rows.
|
|
181
|
+
rows = len(buffer.contents)
|
|
182
|
+
self._check_size(sid, rows, size.height)
|
|
183
|
+
palette = await self._palette_for(session)
|
|
184
|
+
lines = self._render(sid, buffer, palette)
|
|
185
|
+
return ScreenFrame(size.width, rows, palette.fg, palette.bg, lines, ts)
|
|
186
|
+
|
|
187
|
+
def _check_size(self, sid: str, rows: int, grid_rows: int) -> None:
|
|
188
|
+
"""Rediscover (with a refreshed App) once per new mismatch between the buffer and grid_size."""
|
|
189
|
+
if rows == grid_rows:
|
|
190
|
+
self._size_flagged.pop(sid, None)
|
|
191
|
+
return
|
|
192
|
+
if self._size_flagged.get(sid) != (rows, grid_rows):
|
|
193
|
+
self._size_flagged[sid] = (rows, grid_rows)
|
|
194
|
+
self._request_discovery("size-change")
|
|
195
|
+
|
|
196
|
+
def _render(self, sid: str, buffer: api_pb2.GetBufferResponse, palette: Palette) -> list[Row]:
|
|
197
|
+
"""Rows for the buffer, rendering only lines whose content wasn't on the last frame."""
|
|
198
|
+
cache = self._line_cache.get(sid)
|
|
199
|
+
known = cache.rows if cache is not None and cache.palette == palette else {}
|
|
200
|
+
current: dict[bytes, Row] = {}
|
|
201
|
+
lines: list[Row] = []
|
|
202
|
+
for line in buffer.contents:
|
|
203
|
+
raw = line.SerializeToString()
|
|
204
|
+
row = current.get(raw)
|
|
205
|
+
if row is None:
|
|
206
|
+
row = known.get(raw)
|
|
207
|
+
if row is None:
|
|
208
|
+
row = line_row(LineContents(line), palette)
|
|
209
|
+
current[raw] = row
|
|
210
|
+
lines.append(row)
|
|
211
|
+
# Only this frame's lines are kept, so the cache stays bounded to one screen.
|
|
212
|
+
self._line_cache[sid] = _LineCache(palette, current)
|
|
213
|
+
return lines
|
|
214
|
+
|
|
215
|
+
# -- tasks ------------------------------------------------------------------
|
|
216
|
+
async def _main(self) -> None:
|
|
217
|
+
if self._app is None:
|
|
218
|
+
self._app = await iterm2.async_get_app(self._conn)
|
|
219
|
+
for name, body in (
|
|
220
|
+
("layout monitor", lambda: self._monitor(iterm2.LayoutChangeMonitor, "layout")),
|
|
221
|
+
("new-session monitor", lambda: self._monitor(iterm2.NewSessionMonitor, "new")),
|
|
222
|
+
("termination monitor", lambda: self._monitor(iterm2.SessionTerminationMonitor, "terminated")),
|
|
223
|
+
("theme monitor", self._theme_monitor),
|
|
224
|
+
("status loop", self._status_loop),
|
|
225
|
+
("periodic discovery", self._periodic_discovery),
|
|
226
|
+
):
|
|
227
|
+
self._children.add(asyncio.create_task(_keep_running(name, body)))
|
|
228
|
+
self._request_discovery("start")
|
|
229
|
+
await _keep_running("discovery", self._discovery_loop)
|
|
230
|
+
|
|
231
|
+
async def _monitor(self, factory, trigger: str) -> None:
|
|
232
|
+
async with factory(self._conn) as mon:
|
|
233
|
+
while True:
|
|
234
|
+
await mon.async_get()
|
|
235
|
+
if trigger == "layout": # profile or size may have changed: refresh palettes and frames
|
|
236
|
+
self._recolour()
|
|
237
|
+
self._request_discovery(trigger)
|
|
238
|
+
|
|
239
|
+
async def _periodic_discovery(self) -> None:
|
|
240
|
+
"""A safety net next to the events: same debounced pass, so it never reorders cells."""
|
|
241
|
+
while True:
|
|
242
|
+
await asyncio.sleep(self._discovery_interval)
|
|
243
|
+
self._request_discovery("periodic")
|
|
244
|
+
|
|
245
|
+
async def _theme_monitor(self) -> None:
|
|
246
|
+
"""A light/dark switch is not a layout change; watch the app's effectiveTheme."""
|
|
247
|
+
async with iterm2.VariableMonitor(self._conn, iterm2.VariableScopes.APP, "effectiveTheme", None) as mon:
|
|
248
|
+
while True:
|
|
249
|
+
await mon.async_get()
|
|
250
|
+
_log.info("theme changed")
|
|
251
|
+
self._line_cache.clear()
|
|
252
|
+
self._recolour()
|
|
253
|
+
|
|
254
|
+
def _request_discovery(self, trigger: str) -> None:
|
|
255
|
+
self._triggers.add(trigger)
|
|
256
|
+
self._rediscover.set()
|
|
257
|
+
|
|
258
|
+
def _recolour(self) -> None:
|
|
259
|
+
"""Re-resolve every palette on the next fetch and fetch every session again."""
|
|
260
|
+
self._palette_gen += 1
|
|
261
|
+
self._palettes.clear()
|
|
262
|
+
for pump in self._pumps.values():
|
|
263
|
+
pump.mark_dirty()
|
|
264
|
+
|
|
265
|
+
async def _discovery_loop(self) -> None:
|
|
266
|
+
while True:
|
|
267
|
+
await self._rediscover.wait()
|
|
268
|
+
await asyncio.sleep(_DEBOUNCE_SECONDS) # one pass for a burst of events
|
|
269
|
+
self._rediscover.clear()
|
|
270
|
+
triggers, self._triggers = self._triggers, set()
|
|
271
|
+
try:
|
|
272
|
+
await self._discover(triggers)
|
|
273
|
+
except Exception:
|
|
274
|
+
self._triggers |= triggers
|
|
275
|
+
self._rediscover.set() # retry the pass, not just wait for the next event
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
async def _discover(self, triggers: set[str]) -> None:
|
|
279
|
+
start = time.perf_counter()
|
|
280
|
+
if triggers & _REFRESH_TRIGGERS:
|
|
281
|
+
await self._app.async_refresh() # a read: reloads windows, tabs and grid sizes
|
|
282
|
+
found = {i.id: i for i in await list_sessions(self._app, self._excluded, self._has_status)}
|
|
283
|
+
# Cells keep their positions: known sessions stay in first-seen order and new ones
|
|
284
|
+
# (in iTerm2's order among themselves) go at the end. Moving tabs reorders nothing.
|
|
285
|
+
order = [sid for sid in self._infos if sid in found] + [sid for sid in found if sid not in self._infos]
|
|
286
|
+
self._infos = {sid: found[sid] for sid in order}
|
|
287
|
+
infos = list(self._infos.values())
|
|
288
|
+
gone = [sid for sid in self._watchers if sid not in self._infos]
|
|
289
|
+
stopping = [self._watchers.pop(sid) for sid in gone]
|
|
290
|
+
for task in stopping:
|
|
291
|
+
task.cancel()
|
|
292
|
+
for sid in gone:
|
|
293
|
+
for cache in (
|
|
294
|
+
self._pumps, self._palettes, self._line_cache, self._status_locks, self._claude_seen,
|
|
295
|
+
self._last_view, self._size_flagged,
|
|
296
|
+
):
|
|
297
|
+
cache.pop(sid, None)
|
|
298
|
+
self._tracker.forget(sid)
|
|
299
|
+
# Stop vanished sessions' tasks before the hub drops their cached state.
|
|
300
|
+
await asyncio.gather(*stopping, return_exceptions=True)
|
|
301
|
+
await self._hub.set_sessions(infos)
|
|
302
|
+
added = 0
|
|
303
|
+
for info in infos:
|
|
304
|
+
if info.id in self._watchers:
|
|
305
|
+
continue
|
|
306
|
+
session = self._app.get_session_by_id(info.id)
|
|
307
|
+
if session is not None:
|
|
308
|
+
self._watchers[info.id] = asyncio.create_task(self._watch(session))
|
|
309
|
+
added += 1
|
|
310
|
+
_log.info(
|
|
311
|
+
"discovery %s: sessions=%d added=%d removed=%d in %.0f ms",
|
|
312
|
+
",".join(sorted(triggers)), len(infos), added, len(gone), (time.perf_counter() - start) * 1000,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def _watch(self, session) -> None:
|
|
316
|
+
sid = session.session_id
|
|
317
|
+
pump = LatestOnlyPump(lambda: self._publish(session), self._preview_interval)
|
|
318
|
+
self._pumps[sid] = pump
|
|
319
|
+
pump_task = asyncio.create_task(pump.run())
|
|
320
|
+
pump.mark_dirty()
|
|
321
|
+
|
|
322
|
+
async def stream() -> None:
|
|
323
|
+
async with session.get_screen_streamer(want_contents=False) as streamer:
|
|
324
|
+
pump.mark_dirty() # anything that changed while subscribing
|
|
325
|
+
while True:
|
|
326
|
+
await streamer.async_get()
|
|
327
|
+
pump.mark_dirty()
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
await _keep_running(f"streamer {sid}", stream)
|
|
331
|
+
finally:
|
|
332
|
+
pump_task.cancel()
|
|
333
|
+
await asyncio.gather(pump_task, return_exceptions=True)
|
|
334
|
+
|
|
335
|
+
async def _publish(self, session) -> None:
|
|
336
|
+
sid = session.session_id
|
|
337
|
+
frame = await self.fetch_frame(session)
|
|
338
|
+
if frame is not None and sid in self._infos:
|
|
339
|
+
await self._hub.set_screen(sid, frame)
|
|
340
|
+
await self._refresh_status(sid)
|
|
341
|
+
|
|
342
|
+
async def _palette_for(self, session) -> Palette:
|
|
343
|
+
sid = session.session_id
|
|
344
|
+
cached = self._palettes.get(sid)
|
|
345
|
+
if cached is not None:
|
|
346
|
+
return cached
|
|
347
|
+
gen = self._palette_gen
|
|
348
|
+
try:
|
|
349
|
+
profile = await session.async_get_profile()
|
|
350
|
+
theme = await self._app.async_get_variable("effectiveTheme")
|
|
351
|
+
palette = Palette.from_profile(profile, dark="dark" in (theme or ""))
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
_log.info("default palette for %s: %s", sid, type(exc).__name__)
|
|
354
|
+
return DEFAULT_PALETTE # for this frame only: the next fetch tries the profile again
|
|
355
|
+
if gen == self._palette_gen: # not invalidated while we were fetching
|
|
356
|
+
self._palettes[sid] = palette
|
|
357
|
+
return palette
|
|
358
|
+
|
|
359
|
+
# -- status -----------------------------------------------------------------
|
|
360
|
+
async def _has_status(self, sid: str) -> bool:
|
|
361
|
+
raw = await read_status(self._conn, sid)
|
|
362
|
+
if raw is None: # failed read: no new information, so keep what was last known
|
|
363
|
+
return self._claude_seen.setdefault(sid, False)
|
|
364
|
+
present = has_claude_status(raw)
|
|
365
|
+
self._claude_seen[sid] = present
|
|
366
|
+
return present
|
|
367
|
+
|
|
368
|
+
async def _status_loop(self) -> None:
|
|
369
|
+
while True:
|
|
370
|
+
results = await asyncio.gather(
|
|
371
|
+
*(self._refresh_status(sid) for sid in list(self._infos)), return_exceptions=True
|
|
372
|
+
)
|
|
373
|
+
for r in results:
|
|
374
|
+
if isinstance(r, Exception):
|
|
375
|
+
_log.warning("status refresh failed: %s", type(r).__name__)
|
|
376
|
+
await asyncio.sleep(self._status_interval)
|
|
377
|
+
|
|
378
|
+
async def _refresh_status(self, sid: str) -> None:
|
|
379
|
+
if sid not in self._infos:
|
|
380
|
+
return
|
|
381
|
+
async with self._status_locks.setdefault(sid, asyncio.Lock()):
|
|
382
|
+
raw = await read_status(self._conn, sid)
|
|
383
|
+
info = self._infos.get(sid)
|
|
384
|
+
if info is None or raw is None: # vanished while we were reading, or the read failed
|
|
385
|
+
return
|
|
386
|
+
present = has_claude_status(raw)
|
|
387
|
+
if self._claude_seen.get(sid, present) != present:
|
|
388
|
+
self._request_discovery("status-flip") # Claude started or exited: refresh is_claude
|
|
389
|
+
self._claude_seen[sid] = present
|
|
390
|
+
view = classify(raw, is_claude=info.is_claude, job=info.job)
|
|
391
|
+
# changed_at moves only with the tone or label; any other change (detail, colour) is still pushed.
|
|
392
|
+
_, changed_at = self._tracker.update(sid, view)
|
|
393
|
+
if view != self._last_view.get(sid):
|
|
394
|
+
self._last_view[sid] = view
|
|
395
|
+
await self._hub.set_status(sid, view, changed_at)
|