claude-nb 0.3.0 → 0.5.1

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/Makefile +8 -2
  2. package/README.md +57 -36
  3. package/VERSION +1 -1
  4. package/bin/board +112 -34
  5. package/bin/cnb +152 -65
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +13 -47
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/swarm +41 -860
  12. package/bin/sync-version +131 -0
  13. package/lib/board_admin.py +19 -9
  14. package/lib/board_bbs.py +23 -8
  15. package/lib/board_bug.py +2 -1
  16. package/lib/board_db.py +31 -141
  17. package/lib/board_lock.py +5 -1
  18. package/lib/board_mailbox.py +18 -8
  19. package/lib/board_maintenance.py +26 -27
  20. package/lib/board_msg.py +76 -39
  21. package/lib/board_pending.py +233 -0
  22. package/lib/board_pulse.py +14 -0
  23. package/lib/board_task.py +41 -32
  24. package/lib/board_tui.py +120 -0
  25. package/lib/board_view.py +70 -50
  26. package/lib/board_vote.py +9 -3
  27. package/lib/build_lock.py +7 -7
  28. package/lib/common.py +45 -3
  29. package/lib/concerns/__init__.py +7 -11
  30. package/lib/concerns/{coral_manager.py → coral.py} +54 -4
  31. package/lib/concerns/digest_scheduler.py +109 -0
  32. package/lib/concerns/file_watcher.py +73 -68
  33. package/lib/concerns/health.py +136 -0
  34. package/lib/concerns/helpers.py +1 -5
  35. package/lib/concerns/idle.py +130 -0
  36. package/lib/concerns/notification_push.py +171 -0
  37. package/lib/concerns/notifications.py +145 -0
  38. package/lib/concerns/nudge_coordinator.py +148 -0
  39. package/lib/digest.py +62 -0
  40. package/lib/health.py +2 -2
  41. package/lib/inject.py +2 -2
  42. package/lib/migrate.py +1 -0
  43. package/lib/monitor.py +9 -22
  44. package/lib/notification_config.py +101 -0
  45. package/lib/swarm.py +464 -0
  46. package/lib/swarm_backend.py +300 -0
  47. package/lib/theme_profiles.py +89 -0
  48. package/migrations/004_heartbeat.sql +1 -0
  49. package/migrations/005_notification_log.sql +12 -0
  50. package/migrations/006_pending_actions.sql +15 -0
  51. package/package.json +4 -3
  52. package/pyproject.toml +3 -2
  53. package/registry/README.md +9 -0
  54. package/registry/pubkeys.json +2 -1
  55. package/schema.sql +29 -1
  56. package/lib/concerns/bug_sla_checker.py +0 -32
  57. package/lib/concerns/coral_poker.py +0 -57
  58. package/lib/concerns/health_checker.py +0 -72
  59. package/lib/concerns/idle_detector.py +0 -56
  60. package/lib/concerns/idle_killer.py +0 -41
  61. package/lib/concerns/idle_nudger.py +0 -38
  62. package/lib/concerns/inbox_nudger.py +0 -34
  63. package/lib/concerns/resource_monitor.py +0 -47
  64. package/lib/concerns/session_keepalive.py +0 -23
  65. package/lib/concerns/time_announcer.py +0 -34
@@ -0,0 +1,120 @@
1
+ """board_tui — tmux-native team UI with mouse support.
2
+
3
+ Opens a single terminal window with tmux. Each worker is a window (tab).
4
+ Mouse click on the tab bar to switch. That's it.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+ from lib.board_db import BoardDB
12
+
13
+ UI_SESSION = "cnb-ui"
14
+
15
+
16
+ def _tmux(*args: str) -> int:
17
+ return subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5).returncode
18
+
19
+
20
+ def _tmux_out(*args: str) -> str:
21
+ r = subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5)
22
+ return r.stdout.strip() if r.returncode == 0 else ""
23
+
24
+
25
+ def _session_exists(name: str) -> bool:
26
+ return _tmux("has-session", "-t", name) == 0
27
+
28
+
29
+ def cmd_tui(db: BoardDB) -> None:
30
+ """Open team UI: one tmux window per worker, mouse-clickable tabs."""
31
+ if not db.env:
32
+ print("ERROR: 需要完整环境才能启动 TUI")
33
+ raise SystemExit(1)
34
+
35
+ prefix = db.env.prefix
36
+ workers = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all' ORDER BY name")]
37
+
38
+ if not workers:
39
+ print("ERROR: 没有注册的 session")
40
+ raise SystemExit(1)
41
+
42
+ online = [w for w in workers if _session_exists(f"{prefix}-{w}")]
43
+ if not online:
44
+ print("ERROR: 没有在线的 worker,先运行 cnb swarm start")
45
+ raise SystemExit(1)
46
+
47
+ # Rebuild base UI session (shared window group)
48
+ if _session_exists(UI_SESSION):
49
+ _tmux("kill-session", "-t", UI_SESSION)
50
+
51
+ _tmux("new-session", "-d", "-s", UI_SESSION)
52
+ for name in online:
53
+ _tmux("link-window", "-s", f"{prefix}-{name}:0", "-t", UI_SESSION, "-a")
54
+ _tmux("kill-window", "-t", f"{UI_SESSION}:0")
55
+
56
+ win_indices = _tmux_out("list-windows", "-t", UI_SESSION, "-F", "#{window_index}").split("\n")
57
+ for i, name in enumerate(online):
58
+ if i < len(win_indices):
59
+ _tmux("rename-window", "-t", f"{UI_SESSION}:{win_indices[i]}", name)
60
+
61
+ _apply_style(len(online), win_indices)
62
+ _open_terminal()
63
+
64
+
65
+ def _apply_style(n_online: int, win_indices: list[str]) -> None:
66
+ session_opts = {
67
+ "mouse": "on",
68
+ "status": "on",
69
+ "status-position": "top",
70
+ "status-style": "bg=default,fg=white",
71
+ "status-left": " cnb ",
72
+ "status-left-style": "bold",
73
+ "status-left-length": "6",
74
+ "status-right": f" {n_online} online ",
75
+ "status-right-style": "dim",
76
+ }
77
+ for k, v in session_opts.items():
78
+ _tmux("set-option", "-t", UI_SESSION, k, v)
79
+
80
+ window_opts = {
81
+ "window-status-format": " #W ",
82
+ "window-status-current-format": " #W ",
83
+ "window-status-style": "dim",
84
+ "window-status-current-style": "bold,underscore",
85
+ "window-status-separator": "",
86
+ }
87
+ for idx in win_indices:
88
+ for k, v in window_opts.items():
89
+ _tmux("set-option", "-w", "-t", f"{UI_SESSION}:{idx}", k, v)
90
+
91
+
92
+ def _open_terminal() -> None:
93
+ """Open a new terminal attached to cnb-ui."""
94
+ attach_cmd = f"tmux attach -t {UI_SESSION} ';' set-option destroy-unattached on"
95
+
96
+ if sys.platform == "darwin":
97
+ if os.path.isdir("/Applications/iTerm.app"):
98
+ script = (
99
+ 'tell application "iTerm"\n'
100
+ " activate\n"
101
+ f' create window with default profile command "{attach_cmd}"\n'
102
+ "end tell"
103
+ )
104
+ else:
105
+ script = f'tell application "Terminal"\n do script "{attach_cmd}"\n activate\nend tell'
106
+ r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
107
+ if r.returncode != 0:
108
+ print(f"ERROR: 无法打开终端: {r.stderr.strip()}")
109
+ raise SystemExit(1)
110
+ print("OK 已打开 — 点击顶部 tab 切换同学")
111
+ else:
112
+ import shutil
113
+
114
+ for term in ("gnome-terminal", "xterm", "konsole", "alacritty"):
115
+ if shutil.which(term):
116
+ subprocess.Popen([term, "--", "bash", "-c", attach_cmd])
117
+ print("OK 已打开 — 点击顶部 tab 切换同学")
118
+ return
119
+ print(f"ERROR: 找不到终端模拟器,手动运行: {attach_cmd}")
120
+ raise SystemExit(1)
package/lib/board_view.py CHANGED
@@ -15,6 +15,7 @@ def _git(project_root: Path, *args: str) -> str:
15
15
  ["git", "-C", str(project_root), *args],
16
16
  capture_output=True,
17
17
  text=True,
18
+ timeout=5,
18
19
  )
19
20
  return r.stdout
20
21
 
@@ -40,50 +41,63 @@ def _tmux_pane_command(name: str) -> str:
40
41
  return ""
41
42
 
42
43
 
43
- def _pgrep(pattern: str) -> str | None:
44
- try:
45
- r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=3)
46
- if r.returncode == 0:
47
- return r.stdout.strip().split("\n")[0]
48
- except (FileNotFoundError, subprocess.TimeoutExpired):
49
- pass
50
- return None
44
+ def _heartbeat_status(last_heartbeat: str | None, prefix: str, name: str) -> tuple[str, str]:
45
+ """Derive agent liveness from heartbeat timestamp, with tmux fallback."""
46
+ if last_heartbeat:
47
+ try:
48
+ hb_time = datetime.strptime(last_heartbeat, "%Y-%m-%d %H:%M:%S")
49
+ delta = (datetime.now() - hb_time).total_seconds()
50
+ if delta < 120:
51
+ ago = f"[{int(delta)}s ago]"
52
+ return "● active", ago
53
+ elif delta < 180:
54
+ return "◐ thinking", f"[{int(delta / 60)}m ago]"
55
+ elif delta < 600:
56
+ return "○ stale", f"[{int(delta / 60)}m ago]"
57
+ else:
58
+ hours = delta / 3600
59
+ ago = f"[{int(hours)}h ago]" if hours >= 1 else f"[{int(delta / 60)}m ago]"
60
+ return "· offline", ago
61
+ except ValueError:
62
+ pass
63
+ sess = f"{prefix}-{name}"
64
+ if _tmux_has_session(sess):
65
+ cmd = _tmux_pane_command(sess)
66
+ if cmd not in ("zsh", "bash", "sh", "-zsh", "-bash"):
67
+ return "● running", ""
68
+ return "○ dead", ""
69
+ return "· offline", ""
51
70
 
52
71
 
53
72
  def cmd_overview(db: BoardDB) -> None:
54
73
  """Default view when running cnb with no args."""
74
+ assert db.env is not None
55
75
  prefix = db.env.prefix
56
76
  now = datetime.now().strftime("%H:%M")
57
77
  print(f"=== {db.env.project_root.name} {now} ===")
58
78
  print()
59
79
 
60
80
  # ── sessions ──
61
- for (name,) in db.query("SELECT name FROM sessions WHERE name != 'all' ORDER BY name"):
62
- sess = f"{prefix}-{name}"
63
- if _tmux_has_session(sess):
64
- cmd = _tmux_pane_command(sess)
65
- status = "● running" if cmd not in ("zsh", "bash", "sh", "-zsh", "-bash") else "○ dead"
66
- else:
67
- status = "· offline"
81
+ for row in db.query("SELECT name, status, last_heartbeat FROM sessions WHERE name != 'all' ORDER BY name"):
82
+ name, task, last_hb = row[0], row[1], row[2]
83
+ status, ago = _heartbeat_status(last_hb, prefix, name)
68
84
 
69
85
  inbox = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
70
86
  inbox_str = f" [{inbox} msg]" if inbox else ""
71
- task = db.scalar("SELECT status FROM sessions WHERE name=?", (name,)) or ""
72
87
  if task:
73
88
  task = task[:60]
74
89
  else:
75
90
  task = "(no status)"
76
91
 
77
92
  line = f" {status:12s} {name:<10s} {task}"
93
+ if ago:
94
+ line += f" {ago}"
78
95
  if inbox:
79
96
  line += f"{inbox_str}"
80
97
  print(line)
81
98
 
82
99
  # ── recent messages ──
83
- rows = db.query(
84
- "SELECT ts, sender, recipient, substr(body, 1, 80) "
85
- "FROM messages ORDER BY id DESC LIMIT 5"
86
- )
100
+ rows = db.query("SELECT ts, sender, recipient, substr(body, 1, 80) FROM messages ORDER BY id DESC LIMIT 5")
87
101
  if rows:
88
102
  print()
89
103
  print("Recent:")
@@ -91,20 +105,20 @@ def cmd_overview(db: BoardDB) -> None:
91
105
  print(f" [{ts_val}] {sender} → {recipient}: {body}")
92
106
 
93
107
  # ── open proposals ──
94
- proposals = db.query(
95
- "SELECT number || '-' || slug FROM proposals WHERE status='OPEN'"
96
- )
108
+ proposals = db.query("SELECT number || '-' || slug FROM proposals WHERE status='OPEN'")
97
109
  if proposals:
98
110
  print()
99
111
  print(f"Open proposals: {len(proposals)}")
100
112
 
101
113
  # ── dispatcher ──
102
- pid = _pgrep("dispatcher")
114
+ dispatcher_sess = f"{prefix}-dispatcher"
103
115
  print()
104
- if pid:
105
- print(f" dispatcher: running (pid {pid})")
116
+ if _tmux_has_session(dispatcher_sess):
117
+ print(f" dispatcher: running ({dispatcher_sess})")
106
118
  else:
107
- running = any(_tmux_has_session(f"{prefix}-{n}") for (n,) in db.query("SELECT name FROM sessions WHERE name != 'all'"))
119
+ running = any(
120
+ _tmux_has_session(f"{prefix}-{n}") for (n,) in db.query("SELECT name FROM sessions WHERE name != 'all'")
121
+ )
108
122
  if running:
109
123
  print(" dispatcher: NOT RUNNING — run: cnb dispatcher")
110
124
  else:
@@ -112,6 +126,7 @@ def cmd_overview(db: BoardDB) -> None:
112
126
 
113
127
 
114
128
  def cmd_view(db: BoardDB, identity: str) -> None:
129
+ assert db.env is not None
115
130
  print("=== Board ===\n")
116
131
 
117
132
  roadmap = db.env.project_root / "ROADMAP.md"
@@ -130,16 +145,19 @@ def cmd_view(db: BoardDB, identity: str) -> None:
130
145
  if count:
131
146
  print(f">>> 你有 {count} 条未读消息,运行 ./board inbox 查看 <<<\n")
132
147
 
148
+ prefix = db.env.prefix
133
149
  print("Status:")
134
- for name, task in db.query("SELECT name, status FROM sessions ORDER BY name"):
150
+ for name, task, last_hb in db.query("SELECT name, status, last_heartbeat FROM sessions ORDER BY name"):
135
151
  cap = name[0].upper() + name[1:] if name else name
152
+ status, ago = _heartbeat_status(last_hb, prefix, name)
136
153
  task = task or "(none)"
137
154
  tag = ""
138
155
  if p0_locked and "[P0]" not in task:
139
156
  tag = " [!! 未标 P0]"
140
- if len(task) > 72:
141
- task = task[:69] + "..."
142
- print(f" {cap:<8s} {task}{tag}")
157
+ if len(task) > 60:
158
+ task = task[:57] + "..."
159
+ ago_str = f" {ago}" if ago else ""
160
+ print(f" {status:12s} {cap:<10s} {task}{tag}{ago_str}")
143
161
  print()
144
162
 
145
163
  print("Recent messages:")
@@ -166,6 +184,7 @@ def cmd_view(db: BoardDB, identity: str) -> None:
166
184
 
167
185
 
168
186
  def cmd_p0(db: BoardDB) -> None:
187
+ assert db.env is not None
169
188
  roadmap = db.env.project_root / "ROADMAP.md"
170
189
  if not roadmap.is_file():
171
190
  print("ERROR: ROADMAP.md not found")
@@ -193,6 +212,7 @@ def cmd_p0(db: BoardDB) -> None:
193
212
 
194
213
 
195
214
  def cmd_prebuild(db: BoardDB) -> None:
215
+ assert db.env is not None
196
216
  print("=== Pre-build Check ===\n")
197
217
  has_fail = False
198
218
  pr = db.env.project_root
@@ -219,6 +239,7 @@ def cmd_prebuild(db: BoardDB) -> None:
219
239
 
220
240
 
221
241
  def cmd_dirty(db: BoardDB) -> None:
242
+ assert db.env is not None
222
243
  print("=== Uncommitted Changes ===\n")
223
244
  pr = db.env.project_root
224
245
  changes = _git(pr, "status", "--porcelain").strip()
@@ -240,32 +261,29 @@ def cmd_dirty(db: BoardDB) -> None:
240
261
 
241
262
 
242
263
  def cmd_dashboard(db: BoardDB) -> None:
264
+ assert db.env is not None
243
265
  prefix = db.env.prefix
244
266
  print(f"=== Team Dashboard {datetime.now().strftime('%H:%M')} ===\n")
245
- for (name,) in db.query("SELECT name FROM sessions ORDER BY name"):
246
- session_name = f"{prefix}-{name}"
247
- status = "offline"
248
- if _tmux_has_session(session_name):
249
- cmd = _tmux_pane_command(session_name)
250
- if cmd in ("zsh", "bash", "sh", "-zsh", "-bash"):
251
- status = "DEAD"
252
- else:
253
- status = "running"
267
+ for row in db.query("SELECT name, status, last_heartbeat FROM sessions ORDER BY name"):
268
+ name, task, last_hb = row[0], row[1], row[2]
269
+ status, ago = _heartbeat_status(last_hb, prefix, name)
254
270
 
255
271
  inbox_count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
256
272
  inbox_str = f" [{inbox_count}msg]" if inbox_count else ""
257
- task = db.scalar("SELECT substr(status, 1, 50) FROM sessions WHERE name=?", (name,)) or "-"
258
- print(f" {name:<7s} {status:<8s}{inbox_str}")
273
+ task = task[:50] if task else "-"
274
+ ago_str = f" {ago}" if ago else ""
275
+ print(f" {name:<7s} {status:<12s}{inbox_str}{ago_str}")
259
276
  print(f" {task}")
260
277
  print()
261
- pid = _pgrep("dispatcher")
262
- if pid:
263
- print(f" dispatcher: running (PID {pid})")
278
+ dispatcher_sess = f"{prefix}-dispatcher"
279
+ if _tmux_has_session(dispatcher_sess):
280
+ print(f" dispatcher: running ({dispatcher_sess})")
264
281
  else:
265
282
  print(" dispatcher: NOT RUNNING")
266
283
 
267
284
 
268
285
  def cmd_files(db: BoardDB) -> None:
286
+ assert db.env is not None
269
287
  print("=== 共享文件 ===\n")
270
288
  rows = db.query("SELECT hash, original_name, sender, ts FROM files ORDER BY ts DESC")
271
289
  if not rows:
@@ -282,6 +300,7 @@ def cmd_files(db: BoardDB) -> None:
282
300
 
283
301
 
284
302
  def cmd_get(db: BoardDB, args: list[str]) -> None:
303
+ assert db.env is not None
285
304
  if not args:
286
305
  print("Usage: ./board get <hash-prefix|filename>")
287
306
  raise SystemExit(1)
@@ -310,15 +329,15 @@ def cmd_get(db: BoardDB, args: list[str]) -> None:
310
329
 
311
330
  def cmd_freshness(db: BoardDB) -> None:
312
331
  print("=== 数据新鲜度 ===\n")
313
- print(f" {'Session':<8s} {'Last status update':<20s} {'Unread inbox'}")
314
- print(f" {'-------':<8s} {'------------------':<20s} {'------------'}")
332
+ print(f" {'Session':<8s} {'Last status':<20s} {'Last heartbeat':<20s} {'Unread'}")
333
+ print(f" {'-------':<8s} {'-----------':<20s} {'--------------':<20s} {'------'}")
315
334
  rows = db.query(
316
- "SELECT s.name, s.updated_at, "
335
+ "SELECT s.name, s.updated_at, s.last_heartbeat, "
317
336
  "(SELECT COUNT(*) FROM inbox i WHERE i.session=s.name AND i.read=0) "
318
337
  "FROM sessions s ORDER BY s.name"
319
338
  )
320
- for name, updated, inbox_count in rows:
321
- print(f" {name:<8s} {updated or '(never)':<20s} {inbox_count}")
339
+ for name, updated, heartbeat, inbox_count in rows:
340
+ print(f" {name:<8s} {updated or '(never)':<20s} {heartbeat or '(never)':<20s} {inbox_count}")
322
341
 
323
342
 
324
343
  def cmd_relations(db: BoardDB) -> None:
@@ -355,6 +374,7 @@ def cmd_history(db: BoardDB, args: list[str]) -> None:
355
374
 
356
375
 
357
376
  def cmd_roster(db: BoardDB) -> None:
377
+ assert db.env is not None
358
378
  print("=== 员工状态 ===")
359
379
  prefix = db.env.prefix
360
380
  rows = db.query(
package/lib/board_vote.py CHANGED
@@ -1,10 +1,11 @@
1
1
  """board_vote — governance: vote / tally / propose."""
2
2
 
3
3
  from lib.board_db import BoardDB, ts
4
- from lib.common import PRIVILEGED_ROLES, parse_flags
4
+ from lib.common import PRIVILEGED_ROLES, parse_flags, validate_identity
5
5
 
6
6
 
7
7
  def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
8
+ validate_identity(db, identity)
8
9
  name = identity.lower()
9
10
  if name in PRIVILEGED_ROLES:
10
11
  print("ERROR: privileged roles have no voting rights (charter §二)")
@@ -58,7 +59,9 @@ def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
58
59
 
59
60
  prop_type = db.scalar("SELECT type FROM proposals WHERE id=?", (prop_id,))
60
61
  eligible = (
61
- db.scalar("SELECT COUNT(*) FROM sessions WHERE name != (SELECT value FROM meta WHERE key='dispatcher_session')")
62
+ db.scalar(
63
+ "SELECT COUNT(*) FROM sessions WHERE name NOT IN (SELECT value FROM meta WHERE key='dispatcher_session')"
64
+ )
62
65
  or 0
63
66
  )
64
67
  threshold = (eligible * 2 + 2) // 3 if prop_type == "S" else eligible // 2 + 1
@@ -90,6 +93,7 @@ def cmd_vote(db: BoardDB, identity: str, args: list[str]) -> None:
90
93
 
91
94
 
92
95
  def cmd_propose(db: BoardDB, identity: str, args: list[str]) -> None:
96
+ validate_identity(db, identity)
93
97
  name = identity.lower()
94
98
  if len(args) < 1:
95
99
  print("Usage: ./board --as <name> propose <内容> [--type S]")
@@ -157,7 +161,9 @@ def cmd_tally(db: BoardDB, args: list[str]) -> None:
157
161
  )
158
162
  prop_type = db.scalar("SELECT type FROM proposals WHERE id=?", (prop_id,))
159
163
  eligible = (
160
- db.scalar("SELECT COUNT(*) FROM sessions WHERE name != (SELECT value FROM meta WHERE key='dispatcher_session')")
164
+ db.scalar(
165
+ "SELECT COUNT(*) FROM sessions WHERE name NOT IN (SELECT value FROM meta WHERE key='dispatcher_session')"
166
+ )
161
167
  or 0
162
168
  )
163
169
  threshold = (eligible * 2 + 2) // 3 if prop_type == "S" else eligible // 2 + 1
package/lib/build_lock.py CHANGED
@@ -184,22 +184,22 @@ def main() -> None:
184
184
  if cmd == "acquire":
185
185
  if len(args) < 2:
186
186
  print("Usage: build_lock.py acquire <session> <target>", file=sys.stderr)
187
- sys.exit(1)
187
+ raise SystemExit(1)
188
188
  session = args[1]
189
189
  target = args[2] if len(args) > 2 else "unknown"
190
190
  ok, msg = lock.acquire(session, target)
191
191
  print(msg)
192
192
  if not ok:
193
- sys.exit(1)
193
+ raise SystemExit(1)
194
194
 
195
195
  elif cmd == "release":
196
196
  if len(args) < 2:
197
197
  print("Usage: build_lock.py release <session>", file=sys.stderr)
198
- sys.exit(1)
198
+ raise SystemExit(1)
199
199
  ok, msg = lock.release(args[1])
200
200
  print(msg)
201
201
  if not ok:
202
- sys.exit(1)
202
+ raise SystemExit(1)
203
203
 
204
204
  elif cmd == "status":
205
205
  print(lock.status())
@@ -207,14 +207,14 @@ def main() -> None:
207
207
  elif cmd == "wrap":
208
208
  if len(args) < 3:
209
209
  print("Usage: build_lock.py wrap <session> <command...>", file=sys.stderr)
210
- sys.exit(1)
210
+ raise SystemExit(1)
211
211
  session = args[1]
212
212
  command = args[2:]
213
- sys.exit(lock.wrap(session, command))
213
+ raise SystemExit(lock.wrap(session, command))
214
214
 
215
215
  else:
216
216
  print("Usage: build_lock.py {acquire|release|status|wrap} [args...]", file=sys.stderr)
217
- sys.exit(1)
217
+ raise SystemExit(1)
218
218
 
219
219
 
220
220
  if __name__ == "__main__":
package/lib/common.py CHANGED
@@ -12,13 +12,21 @@ from typing import Any, Generic, TypeVar
12
12
 
13
13
 
14
14
  def find_claudes_dir() -> Path:
15
- """Walk up from cwd to find the .claudes/ directory."""
15
+ """Find .claudes/ directory. Checks CNB_PROJECT env var first, then walks up from cwd."""
16
+ import os
17
+
18
+ env_root = os.environ.get("CNB_PROJECT")
19
+ if env_root:
20
+ p = Path(env_root) / ".claudes"
21
+ if p.is_dir():
22
+ return p
23
+
16
24
  d = Path.cwd()
17
25
  while d != d.parent:
18
26
  if (d / ".claudes").is_dir():
19
27
  return d / ".claudes"
20
28
  d = d.parent
21
- raise FileNotFoundError(".claudes/ not found")
29
+ raise FileNotFoundError(".claudes/ not found (set CNB_PROJECT or run from project dir)")
22
30
 
23
31
 
24
32
  def _parse_toml(path: Path) -> dict:
@@ -28,6 +36,30 @@ def _parse_toml(path: Path) -> dict:
28
36
  return tomllib.loads(path.read_text())
29
37
 
30
38
 
39
+ def _write_config_toml(path: Path, data: dict) -> None:
40
+ """Serialize *data* back to our config.toml format."""
41
+ lines: list[str] = []
42
+ for key, val in data.items():
43
+ if key == "session":
44
+ continue
45
+ if isinstance(val, list):
46
+ items = ", ".join(f'"{v}"' for v in val)
47
+ lines.append(f"{key} = [{items}]")
48
+ else:
49
+ lines.append(f'{key} = "{val}"')
50
+ lines.append("")
51
+ for name, section in data.get("session", {}).items():
52
+ lines.append(f"[session.{name}]")
53
+ for k, v in section.items():
54
+ sv = str(v)
55
+ if "\n" in sv:
56
+ lines.append(f'{k} = """{sv}"""')
57
+ else:
58
+ lines.append(f'{k} = "{sv}"')
59
+ lines.append("")
60
+ path.write_text("\n".join(lines) + "\n")
61
+
62
+
31
63
  @dataclass
32
64
  class ClaudesEnv:
33
65
  claudes_dir: Path
@@ -84,6 +116,16 @@ def is_privileged(name: str) -> bool:
84
116
  return name in PRIVILEGED_ROLES
85
117
 
86
118
 
119
+ def validate_identity(db: "BaseDB", identity: str) -> None:
120
+ name = identity.lower()
121
+ if name in PRIVILEGED_ROLES:
122
+ return
123
+ exists = db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (name,))
124
+ if not exists:
125
+ print(f"ERROR: '{name}' is not a registered session")
126
+ raise SystemExit(1)
127
+
128
+
87
129
  def is_terminal_task_status(status: str) -> bool:
88
130
  """Return True if *status* is a terminal state (task will not transition further).
89
131
 
@@ -190,7 +232,7 @@ class DB(BaseDB):
190
232
 
191
233
  def execute(self, sql: str, params: tuple[Any, ...] = ()) -> int:
192
234
  with self.conn() as c:
193
- return c.execute(sql, params).lastrowid
235
+ return c.execute(sql, params).lastrowid or 0
194
236
 
195
237
 
196
238
  # ---------------------------------------------------------------------------
@@ -5,20 +5,14 @@ Each concern is a self-contained module with its own check interval.
5
5
 
6
6
  from .adaptive_throttle import AdaptiveThrottle
7
7
  from .base import Concern
8
- from .bug_sla_checker import BugSLAChecker
9
8
  from .config import DispatcherConfig
10
- from .coral_manager import CoralManager
11
- from .coral_poker import CoralPoker
9
+ from .coral import CoralManager, CoralPoker
12
10
  from .file_watcher import FileWatcher
13
- from .health_checker import HealthChecker
11
+ from .health import HealthChecker, ResourceMonitor, SessionKeepAlive
14
12
  from .helpers import log, tmux_ok, warn
15
- from .idle_detector import IdleDetector
16
- from .idle_killer import IdleKiller
17
- from .idle_nudger import IdleNudger
18
- from .inbox_nudger import InboxNudger
19
- from .resource_monitor import ResourceMonitor
20
- from .session_keepalive import SessionKeepAlive
21
- from .time_announcer import TimeAnnouncer
13
+ from .idle import IdleDetector, IdleKiller, IdleNudger
14
+ from .notifications import BugSLAChecker, InboxNudger, QueuedMessageFlusher, TimeAnnouncer
15
+ from .nudge_coordinator import NudgeCoordinator
22
16
 
23
17
  __all__ = [
24
18
  "AdaptiveThrottle",
@@ -33,6 +27,8 @@ __all__ = [
33
27
  "IdleKiller",
34
28
  "IdleNudger",
35
29
  "InboxNudger",
30
+ "NudgeCoordinator",
31
+ "QueuedMessageFlusher",
36
32
  "ResourceMonitor",
37
33
  "SessionKeepAlive",
38
34
  "TimeAnnouncer",
@@ -1,12 +1,13 @@
1
- """CoralManager ensure dispatcher Claude session is running."""
1
+ """Coral: dispatcher session lifecycle management and heartbeat."""
2
2
 
3
+ import re
3
4
  import time
4
5
 
5
6
  from lib.common import is_suspended
6
7
 
7
8
  from .base import Concern
8
9
  from .config import DispatcherConfig
9
- from .helpers import is_claude_running, log, tmux, tmux_send
10
+ from .helpers import db, is_claude_running, log, pane_md5, tmux, tmux_ok, tmux_send
10
11
 
11
12
 
12
13
  class CoralManager(Concern):
@@ -37,7 +38,7 @@ class CoralManager(Concern):
37
38
  log("Starting Coral...")
38
39
  tmux("kill-session", "-t", self.cfg.coral_sess)
39
40
  tmux("new-session", "-d", "-s", self.cfg.coral_sess, "-x", "200", "-y", "50")
40
- tmux_send(self.cfg.coral_sess, f"cd '{self.cfg.project_root}'")
41
+ tmux_send(self.cfg.coral_sess, f"cd '{self.cfg.project_root}' && export CNB_PROJECT='{self.cfg.project_root}'")
41
42
  time.sleep(0.5)
42
43
  tmux_send(
43
44
  self.cfg.coral_sess,
@@ -49,8 +50,8 @@ class CoralManager(Concern):
49
50
  self._wait_until_ready()
50
51
 
51
52
  def _wait_until_ready(self) -> None:
52
- """Poll until the Claude process is running, or timeout after BOOT_WAIT."""
53
53
  import time as _time
54
+
54
55
  deadline = _time.monotonic() + self.BOOT_WAIT
55
56
  while _time.monotonic() < deadline:
56
57
  if is_claude_running(self.cfg.coral_sess):
@@ -59,3 +60,52 @@ class CoralManager(Concern):
59
60
  return
60
61
  _time.sleep(1)
61
62
  log(f"WARNING: Coral not ready after {self.BOOT_WAIT}s, will retry next tick")
63
+
64
+
65
+ class CoralPoker(Concern):
66
+ interval = 120
67
+
68
+ def __init__(self, cfg: DispatcherConfig) -> None:
69
+ super().__init__()
70
+ self.cfg = cfg
71
+ self.last_poke: int = int(time.time())
72
+
73
+ def poke(self, msg: str) -> bool:
74
+ if not tmux_ok("has-session", "-t", self.cfg.coral_sess) or not is_claude_running(self.cfg.coral_sess):
75
+ return False
76
+
77
+ content = tmux("capture-pane", "-t", self.cfg.coral_sess, "-p") or ""
78
+ prompts = [l for l in content.splitlines() if l.startswith("❯")]
79
+ if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
80
+ log("Coral: skip (typing)")
81
+ return False
82
+
83
+ h1 = pane_md5(self.cfg.coral_sess)
84
+ time.sleep(1)
85
+ if h1 != pane_md5(self.cfg.coral_sess):
86
+ log("Coral: skip (busy)")
87
+ return False
88
+
89
+ log("Coral: poking")
90
+ tmux_send(self.cfg.coral_sess, msg)
91
+ self.last_poke = int(time.time())
92
+ return True
93
+
94
+ def tick(self, now: int) -> None:
95
+ unread = 0
96
+ if self.cfg.board_db.exists():
97
+ try:
98
+ unread = (
99
+ db(self.cfg).scalar(
100
+ "SELECT COUNT(*) FROM inbox WHERE session=? AND read=0",
101
+ (self.cfg.dispatcher_session,),
102
+ )
103
+ or 0
104
+ )
105
+ except Exception:
106
+ pass
107
+
108
+ if unread > 0:
109
+ self.poke(f"[Dispatcher] 你有 {unread} 条未读消息")
110
+ elif (now - self.last_poke) >= self.interval:
111
+ self.poke(f"[Dispatcher] heartbeat {time.strftime('%H:%M:%S')}")