claude-nb 0.3.0
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.
- package/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- package/schema.sql +138 -0
package/lib/monitor.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""monitor.py -- File change monitoring (kqueue / inotify / poll).
|
|
3
|
+
|
|
4
|
+
Event-driven file watching to detect session file changes instantly.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
./lib/monitor.py # watch and react
|
|
8
|
+
./lib/monitor.py --test # send a test message and measure latency
|
|
9
|
+
./lib/monitor.py --benchmark # compare event vs polling latency
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import select
|
|
14
|
+
import signal
|
|
15
|
+
import sqlite3
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Try to import common; fall back gracefully for standalone use
|
|
22
|
+
try:
|
|
23
|
+
from lib.board_db import BoardDB
|
|
24
|
+
from lib.common import ClaudesEnv
|
|
25
|
+
except ImportError:
|
|
26
|
+
_here = Path(__file__).resolve().parent.parent
|
|
27
|
+
sys.path.insert(0, str(_here))
|
|
28
|
+
from lib.board_db import BoardDB
|
|
29
|
+
from lib.common import ClaudesEnv
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def log(msg: str) -> None:
|
|
33
|
+
now = time.strftime("%H:%M:%S")
|
|
34
|
+
ms = f"{time.time() % 1:.3f}"[1:]
|
|
35
|
+
print(f"[monitor] {now}{ms} {msg}", flush=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def has_kqueue() -> bool:
|
|
39
|
+
return hasattr(select, "kqueue")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def has_inotifywait() -> bool:
|
|
43
|
+
import shutil
|
|
44
|
+
|
|
45
|
+
return shutil.which("inotifywait") is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Kqueue watcher (macOS)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KqueueWatcher:
|
|
54
|
+
"""Watch a directory and its .md files for changes using kqueue."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, watch_dir: str) -> None:
|
|
57
|
+
self.watch_dir = watch_dir
|
|
58
|
+
self.kq = select.kqueue()
|
|
59
|
+
self.dir_fd = os.open(watch_dir, os.O_RDONLY)
|
|
60
|
+
dir_event = select.kevent(
|
|
61
|
+
self.dir_fd,
|
|
62
|
+
filter=select.KQ_FILTER_VNODE,
|
|
63
|
+
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
|
|
64
|
+
fflags=select.KQ_NOTE_WRITE,
|
|
65
|
+
)
|
|
66
|
+
self.kq.control([dir_event], 0)
|
|
67
|
+
self.file_fds: dict[str, int] = {}
|
|
68
|
+
self._refresh()
|
|
69
|
+
|
|
70
|
+
def _refresh(self) -> None:
|
|
71
|
+
"""Register watches on any new .md files."""
|
|
72
|
+
for f in os.listdir(self.watch_dir):
|
|
73
|
+
if not f.endswith(".md"):
|
|
74
|
+
continue
|
|
75
|
+
path = os.path.join(self.watch_dir, f)
|
|
76
|
+
if path in self.file_fds:
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
fd = os.open(path, os.O_RDONLY)
|
|
80
|
+
ev = select.kevent(
|
|
81
|
+
fd,
|
|
82
|
+
filter=select.KQ_FILTER_VNODE,
|
|
83
|
+
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
|
|
84
|
+
fflags=select.KQ_NOTE_WRITE | select.KQ_NOTE_EXTEND,
|
|
85
|
+
)
|
|
86
|
+
self.kq.control([ev], 0)
|
|
87
|
+
self.file_fds[path] = fd
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def poll(self, timeout: float = 5.0) -> set:
|
|
92
|
+
"""Block up to *timeout* seconds, return set of changed file paths."""
|
|
93
|
+
fd_to_path = {fd: path for path, fd in self.file_fds.items()}
|
|
94
|
+
try:
|
|
95
|
+
events = self.kq.control(None, 8, timeout)
|
|
96
|
+
except (InterruptedError, OSError):
|
|
97
|
+
return set()
|
|
98
|
+
|
|
99
|
+
if not events:
|
|
100
|
+
self._refresh()
|
|
101
|
+
return set()
|
|
102
|
+
|
|
103
|
+
changed = set()
|
|
104
|
+
for ev in events:
|
|
105
|
+
if ev.ident == self.dir_fd:
|
|
106
|
+
self._refresh()
|
|
107
|
+
elif ev.ident in fd_to_path:
|
|
108
|
+
changed.add(fd_to_path[ev.ident])
|
|
109
|
+
|
|
110
|
+
if not changed:
|
|
111
|
+
self._refresh()
|
|
112
|
+
return changed
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
for fd in self.file_fds.values():
|
|
116
|
+
try:
|
|
117
|
+
os.close(fd)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
try:
|
|
121
|
+
os.close(self.dir_fd)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
self.kq.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Inotify watcher (Linux)
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class InotifyWatcher:
|
|
133
|
+
"""Watch via inotifywait subprocess."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, watch_dir: str) -> None:
|
|
136
|
+
self.proc = subprocess.Popen(
|
|
137
|
+
["inotifywait", "-m", "-e", "modify,create", "--format", "%w%f", watch_dir + "/"],
|
|
138
|
+
stdout=subprocess.PIPE,
|
|
139
|
+
stderr=subprocess.DEVNULL,
|
|
140
|
+
text=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def poll(self, timeout: float = 5.0) -> set:
|
|
144
|
+
import selectors
|
|
145
|
+
|
|
146
|
+
sel = selectors.DefaultSelector()
|
|
147
|
+
sel.register(self.proc.stdout, selectors.EVENT_READ)
|
|
148
|
+
changed = set()
|
|
149
|
+
events = sel.select(timeout=timeout)
|
|
150
|
+
for key, _ in events:
|
|
151
|
+
line = key.fileobj.readline().strip()
|
|
152
|
+
if line:
|
|
153
|
+
changed.add(line)
|
|
154
|
+
sel.close()
|
|
155
|
+
return changed
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
if self.proc.poll() is None:
|
|
159
|
+
self.proc.terminate()
|
|
160
|
+
self.proc.wait()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Polling watcher (fallback)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class PollWatcher:
|
|
169
|
+
"""Fall back to 1s stat polling."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, watch_dir: str) -> None:
|
|
172
|
+
self.watch_dir = watch_dir
|
|
173
|
+
self.mtimes: dict[str, float] = {}
|
|
174
|
+
# Initialize mtimes
|
|
175
|
+
self._scan(init=True)
|
|
176
|
+
|
|
177
|
+
def _scan(self, init: bool = False) -> set:
|
|
178
|
+
changed = set()
|
|
179
|
+
for f in os.listdir(self.watch_dir):
|
|
180
|
+
if not f.endswith(".md"):
|
|
181
|
+
continue
|
|
182
|
+
path = os.path.join(self.watch_dir, f)
|
|
183
|
+
try:
|
|
184
|
+
mt = os.path.getmtime(path)
|
|
185
|
+
except OSError:
|
|
186
|
+
continue
|
|
187
|
+
prev = self.mtimes.get(path)
|
|
188
|
+
if not init and prev is not None and mt != prev:
|
|
189
|
+
changed.add(path)
|
|
190
|
+
self.mtimes[path] = mt
|
|
191
|
+
return changed
|
|
192
|
+
|
|
193
|
+
def poll(self, timeout: float = 1.0) -> set:
|
|
194
|
+
time.sleep(timeout)
|
|
195
|
+
return self._scan()
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Unified watcher factory
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def create_watcher(watch_dir: str):
|
|
207
|
+
"""Return the best available watcher for the platform."""
|
|
208
|
+
if has_kqueue():
|
|
209
|
+
return KqueueWatcher(watch_dir)
|
|
210
|
+
elif has_inotifywait():
|
|
211
|
+
return InotifyWatcher(watch_dir)
|
|
212
|
+
else:
|
|
213
|
+
return PollWatcher(watch_dir)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Event handler
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def handle_change(file_path: str, env: ClaudesEnv) -> None:
|
|
222
|
+
"""Check for unread inbox and nudge the session."""
|
|
223
|
+
name = os.path.basename(file_path).replace(".md", "")
|
|
224
|
+
|
|
225
|
+
unread = 0
|
|
226
|
+
if env.board_db.exists():
|
|
227
|
+
try:
|
|
228
|
+
db = BoardDB(env.board_db)
|
|
229
|
+
unread = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
|
|
230
|
+
except (sqlite3.Error, OSError):
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
if unread > 0:
|
|
234
|
+
sess = f"{env.prefix}-{name}"
|
|
235
|
+
try:
|
|
236
|
+
r = subprocess.run(
|
|
237
|
+
["tmux", "has-session", "-t", sess],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
timeout=5,
|
|
240
|
+
)
|
|
241
|
+
if r.returncode == 0:
|
|
242
|
+
log(f"EVENT: {name} has {unread} unread -- nudging")
|
|
243
|
+
subprocess.run(
|
|
244
|
+
["tmux", "send-keys", "-t", sess, "-l", f"./board --as {name} inbox"],
|
|
245
|
+
timeout=5,
|
|
246
|
+
)
|
|
247
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=5)
|
|
248
|
+
else:
|
|
249
|
+
log(f"EVENT: {name} has {unread} unread -- session not running")
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# CLI modes
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def do_watch(env: ClaudesEnv) -> None:
|
|
260
|
+
watcher = create_watcher(str(env.sessions_dir))
|
|
261
|
+
log(f"Starting {type(watcher).__name__} on {env.sessions_dir}/")
|
|
262
|
+
|
|
263
|
+
running = True
|
|
264
|
+
|
|
265
|
+
def _stop(*_):
|
|
266
|
+
nonlocal running
|
|
267
|
+
running = False
|
|
268
|
+
|
|
269
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
270
|
+
signal.signal(signal.SIGINT, _stop)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
while running:
|
|
274
|
+
changed = watcher.poll(5.0)
|
|
275
|
+
for path in changed:
|
|
276
|
+
handle_change(path, env)
|
|
277
|
+
finally:
|
|
278
|
+
watcher.close()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def do_test(env: ClaudesEnv) -> None:
|
|
282
|
+
log("=== Latency Test ===")
|
|
283
|
+
log("Sending test message and measuring detection time...")
|
|
284
|
+
|
|
285
|
+
test_target = env.sessions[0] if env.sessions else "test"
|
|
286
|
+
board_sh = env.install_home / "bin" / "board"
|
|
287
|
+
|
|
288
|
+
# Start watcher
|
|
289
|
+
watcher = create_watcher(str(env.sessions_dir))
|
|
290
|
+
|
|
291
|
+
start_ms = int(time.time() * 1000)
|
|
292
|
+
try:
|
|
293
|
+
subprocess.run(
|
|
294
|
+
[
|
|
295
|
+
str(board_sh),
|
|
296
|
+
"--as",
|
|
297
|
+
"dispatcher",
|
|
298
|
+
"send",
|
|
299
|
+
test_target,
|
|
300
|
+
f"[monitor-poc] latency test {int(time.time())}",
|
|
301
|
+
],
|
|
302
|
+
capture_output=True,
|
|
303
|
+
timeout=10,
|
|
304
|
+
)
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
# Wait for detection (max 5s)
|
|
309
|
+
for _ in range(50):
|
|
310
|
+
changed = watcher.poll(0.1)
|
|
311
|
+
if changed:
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
end_ms = int(time.time() * 1000)
|
|
315
|
+
watcher.close()
|
|
316
|
+
|
|
317
|
+
latency = end_ms - start_ms
|
|
318
|
+
log(f"Event detected in {latency}ms")
|
|
319
|
+
log("vs polling at 30s interval: avg 15000ms latency")
|
|
320
|
+
if latency > 0:
|
|
321
|
+
log(f"Improvement: ~{15000 // latency}x faster")
|
|
322
|
+
|
|
323
|
+
# Clean up test message
|
|
324
|
+
try:
|
|
325
|
+
subprocess.run(
|
|
326
|
+
[str(board_sh), "--as", test_target, "ack"],
|
|
327
|
+
capture_output=True,
|
|
328
|
+
timeout=10,
|
|
329
|
+
)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def do_benchmark(env: ClaudesEnv) -> None:
|
|
335
|
+
log("=== Event vs Polling Benchmark ===")
|
|
336
|
+
log("")
|
|
337
|
+
log("Event-driven (kqueue/inotify):")
|
|
338
|
+
log(" - Detection latency: <100ms typical")
|
|
339
|
+
log(" - CPU usage: near-zero (kernel callback)")
|
|
340
|
+
log(" - Scalability: O(1) per event")
|
|
341
|
+
log("")
|
|
342
|
+
log("Polling (current dispatcher, 30s):")
|
|
343
|
+
log(" - Detection latency: 0-30000ms (avg 15000ms)")
|
|
344
|
+
log(" - CPU usage: periodic wake + file reads")
|
|
345
|
+
log(" - Scalability: O(n) per interval (n = sessions)")
|
|
346
|
+
log("")
|
|
347
|
+
log("Running live test...")
|
|
348
|
+
do_test(env)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main() -> None:
|
|
352
|
+
env = ClaudesEnv.load()
|
|
353
|
+
arg = sys.argv[1] if len(sys.argv) > 1 else "watch"
|
|
354
|
+
|
|
355
|
+
if arg in ("watch", "--watch"):
|
|
356
|
+
do_watch(env)
|
|
357
|
+
elif arg == "--test":
|
|
358
|
+
do_test(env)
|
|
359
|
+
elif arg == "--benchmark":
|
|
360
|
+
do_benchmark(env)
|
|
361
|
+
elif arg in ("--help", "-h"):
|
|
362
|
+
print("monitor.py -- Event-driven file change monitoring")
|
|
363
|
+
print()
|
|
364
|
+
print(" watch Start file watcher (default)")
|
|
365
|
+
print(" --test Measure detection latency")
|
|
366
|
+
print(" --benchmark Compare event vs polling")
|
|
367
|
+
else:
|
|
368
|
+
print(f"Unknown: {arg}", file=sys.stderr)
|
|
369
|
+
sys.exit(1)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
main()
|
package/lib/panel.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""panel.py -- Auto-refreshing team status panel.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
./lib/panel.py [interval_seconds]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import select
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Try to import common; fall back gracefully for standalone use
|
|
16
|
+
try:
|
|
17
|
+
from lib.common import ClaudesEnv
|
|
18
|
+
except ImportError:
|
|
19
|
+
_here = Path(__file__).resolve().parent.parent
|
|
20
|
+
sys.path.insert(0, str(_here))
|
|
21
|
+
from lib.common import ClaudesEnv
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _tmux(*args: str) -> str | None:
|
|
25
|
+
try:
|
|
26
|
+
r = subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5)
|
|
27
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def status_icon(prefix: str, name: str) -> str:
|
|
33
|
+
"""Return a 2-char status icon for the session."""
|
|
34
|
+
sess = f"{prefix}-{name}"
|
|
35
|
+
if _tmux("has-session", "-t", sess) is None:
|
|
36
|
+
return " "
|
|
37
|
+
|
|
38
|
+
cmd = _tmux("list-panes", "-t", sess, "-F", "#{pane_current_command}")
|
|
39
|
+
first = (cmd or "").splitlines()[0] if cmd else ""
|
|
40
|
+
if first in ("zsh", "bash", "sh", "-zsh", "-bash", ""):
|
|
41
|
+
return "!!"
|
|
42
|
+
|
|
43
|
+
output = _tmux("capture-pane", "-t", sess, "-p")
|
|
44
|
+
if output is None:
|
|
45
|
+
return "~~"
|
|
46
|
+
|
|
47
|
+
tail = "\n".join(output.splitlines()[-8:])
|
|
48
|
+
if "bypass permissions" in tail:
|
|
49
|
+
return ".."
|
|
50
|
+
if re.search(r"^\s*(⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|●)", tail, re.MULTILINE):
|
|
51
|
+
return ">>"
|
|
52
|
+
return "~~"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render(env: ClaudesEnv, interval: int) -> None:
|
|
56
|
+
"""Clear screen and print the team panel."""
|
|
57
|
+
# Clear screen
|
|
58
|
+
print("\033[2J\033[H", end="")
|
|
59
|
+
now = time.strftime("%H:%M:%S")
|
|
60
|
+
print(f"\033[1m TEAM PANEL\033[0m {now}")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
for name in env.sessions:
|
|
64
|
+
sf = env.sessions_dir / f"{name}.md"
|
|
65
|
+
icon = status_icon(env.prefix, name)
|
|
66
|
+
|
|
67
|
+
task = "-"
|
|
68
|
+
if sf.exists():
|
|
69
|
+
lines = sf.read_text().splitlines()
|
|
70
|
+
for i, line in enumerate(lines):
|
|
71
|
+
if line.startswith("## Status"):
|
|
72
|
+
if i + 1 < len(lines) and lines[i + 1].strip():
|
|
73
|
+
task = lines[i + 1].strip()
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
# Color by icon
|
|
77
|
+
color = "\033[90m" # dim
|
|
78
|
+
if icon == ">>":
|
|
79
|
+
color = "\033[32m" # green
|
|
80
|
+
elif icon == "..":
|
|
81
|
+
color = "\033[33m" # yellow
|
|
82
|
+
elif icon == "!!":
|
|
83
|
+
color = "\033[31m" # red
|
|
84
|
+
|
|
85
|
+
print(f" {color}{icon} {name:<6}\033[0m {task}")
|
|
86
|
+
|
|
87
|
+
print(f"\n \033[90m{interval} 秒刷新 q 退出\033[0m")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
env = ClaudesEnv.load()
|
|
92
|
+
interval = int(sys.argv[1]) if len(sys.argv) > 1 else 8
|
|
93
|
+
|
|
94
|
+
# Hide cursor
|
|
95
|
+
print("\033[?25l", end="", flush=True)
|
|
96
|
+
|
|
97
|
+
import signal
|
|
98
|
+
|
|
99
|
+
def _restore(*_):
|
|
100
|
+
print("\033[?25h", end="", flush=True) # show cursor
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
|
|
103
|
+
signal.signal(signal.SIGINT, _restore)
|
|
104
|
+
signal.signal(signal.SIGTERM, _restore)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
while True:
|
|
108
|
+
render(env, interval)
|
|
109
|
+
|
|
110
|
+
# Wait for interval, checking for 'q' key
|
|
111
|
+
deadline = time.time() + interval
|
|
112
|
+
while time.time() < deadline:
|
|
113
|
+
# Non-blocking stdin read (Unix only)
|
|
114
|
+
import termios
|
|
115
|
+
import tty
|
|
116
|
+
|
|
117
|
+
old_settings = termios.tcgetattr(sys.stdin)
|
|
118
|
+
try:
|
|
119
|
+
tty.setcbreak(sys.stdin.fileno())
|
|
120
|
+
rlist, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
121
|
+
if rlist:
|
|
122
|
+
ch = sys.stdin.read(1)
|
|
123
|
+
if ch == "q":
|
|
124
|
+
_restore()
|
|
125
|
+
except Exception:
|
|
126
|
+
time.sleep(0.1)
|
|
127
|
+
finally:
|
|
128
|
+
try:
|
|
129
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
finally:
|
|
133
|
+
print("\033[?25h", end="", flush=True)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|