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.
Files changed (65) hide show
  1. package/LICENSE +38 -0
  2. package/Makefile +60 -0
  3. package/README.md +63 -0
  4. package/VERSION +1 -0
  5. package/bin/_pip_entry.py +25 -0
  6. package/bin/board +287 -0
  7. package/bin/cnb +150 -0
  8. package/bin/cnb.js +33 -0
  9. package/bin/dispatcher +151 -0
  10. package/bin/dispatcher-watchdog +57 -0
  11. package/bin/doctor +328 -0
  12. package/bin/init +316 -0
  13. package/bin/registry +347 -0
  14. package/bin/swarm +896 -0
  15. package/lib/__init__.py +1 -0
  16. package/lib/board_admin.py +128 -0
  17. package/lib/board_bbs.py +99 -0
  18. package/lib/board_bug.py +161 -0
  19. package/lib/board_db.py +262 -0
  20. package/lib/board_lock.py +113 -0
  21. package/lib/board_mailbox.py +145 -0
  22. package/lib/board_maintenance.py +237 -0
  23. package/lib/board_msg.py +230 -0
  24. package/lib/board_task.py +200 -0
  25. package/lib/board_view.py +366 -0
  26. package/lib/board_vote.py +164 -0
  27. package/lib/build_lock.py +221 -0
  28. package/lib/cli.py +34 -0
  29. package/lib/common.py +285 -0
  30. package/lib/concerns/__init__.py +42 -0
  31. package/lib/concerns/adaptive_throttle.py +26 -0
  32. package/lib/concerns/base.py +25 -0
  33. package/lib/concerns/bug_sla_checker.py +32 -0
  34. package/lib/concerns/config.py +22 -0
  35. package/lib/concerns/coral_manager.py +61 -0
  36. package/lib/concerns/coral_poker.py +57 -0
  37. package/lib/concerns/file_watcher.py +127 -0
  38. package/lib/concerns/health_checker.py +72 -0
  39. package/lib/concerns/helpers.py +152 -0
  40. package/lib/concerns/idle_detector.py +56 -0
  41. package/lib/concerns/idle_killer.py +41 -0
  42. package/lib/concerns/idle_nudger.py +38 -0
  43. package/lib/concerns/inbox_nudger.py +34 -0
  44. package/lib/concerns/resource_monitor.py +47 -0
  45. package/lib/concerns/session_keepalive.py +23 -0
  46. package/lib/concerns/time_announcer.py +34 -0
  47. package/lib/crypto.py +92 -0
  48. package/lib/health.py +187 -0
  49. package/lib/inject.py +164 -0
  50. package/lib/migrate.py +109 -0
  51. package/lib/monitor.py +373 -0
  52. package/lib/panel.py +137 -0
  53. package/lib/resources.py +341 -0
  54. package/migrations/001_foreign_keys.sql +77 -0
  55. package/migrations/002_session_persona.sql +1 -0
  56. package/migrations/003_mailbox.sql +9 -0
  57. package/package.json +28 -0
  58. package/pyproject.toml +71 -0
  59. package/registry/0001-meridian.json +12 -0
  60. package/registry/0002-forge.json +12 -0
  61. package/registry/0003-lead.json +12 -0
  62. package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
  63. package/registry/GENESIS.json +9 -0
  64. package/registry/pubkeys.json +5 -0
  65. 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()