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.
- package/Makefile +8 -2
- package/README.md +57 -36
- package/VERSION +1 -1
- package/bin/board +112 -34
- package/bin/cnb +152 -65
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +13 -47
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/swarm +41 -860
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +31 -141
- package/lib/board_lock.py +5 -1
- package/lib/board_mailbox.py +18 -8
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +76 -39
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +41 -32
- package/lib/board_tui.py +120 -0
- package/lib/board_view.py +70 -50
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +7 -11
- package/lib/concerns/{coral_manager.py → coral.py} +54 -4
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +145 -0
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +9 -22
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +464 -0
- package/lib/swarm_backend.py +300 -0
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/registry/pubkeys.json +2 -1
- package/schema.sql +29 -1
- package/lib/concerns/bug_sla_checker.py +0 -32
- package/lib/concerns/coral_poker.py +0 -57
- package/lib/concerns/health_checker.py +0 -72
- package/lib/concerns/idle_detector.py +0 -56
- package/lib/concerns/idle_killer.py +0 -41
- package/lib/concerns/idle_nudger.py +0 -38
- package/lib/concerns/inbox_nudger.py +0 -34
- package/lib/concerns/resource_monitor.py +0 -47
- package/lib/concerns/session_keepalive.py +0 -23
- package/lib/concerns/time_announcer.py +0 -34
package/lib/board_tui.py
ADDED
|
@@ -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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
114
|
+
dispatcher_sess = f"{prefix}-dispatcher"
|
|
103
115
|
print()
|
|
104
|
-
if
|
|
105
|
-
print(f" dispatcher: running (
|
|
116
|
+
if _tmux_has_session(dispatcher_sess):
|
|
117
|
+
print(f" dispatcher: running ({dispatcher_sess})")
|
|
106
118
|
else:
|
|
107
|
-
running = any(
|
|
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) >
|
|
141
|
-
task = task[:
|
|
142
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
status =
|
|
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 =
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
if
|
|
263
|
-
print(f" dispatcher: running (
|
|
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
|
|
314
|
-
print(f" {'-------':<8s} {'
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
raise SystemExit(1)
|
|
199
199
|
ok, msg = lock.release(args[1])
|
|
200
200
|
print(msg)
|
|
201
201
|
if not ok:
|
|
202
|
-
|
|
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
|
-
|
|
210
|
+
raise SystemExit(1)
|
|
211
211
|
session = args[1]
|
|
212
212
|
command = args[2:]
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
# ---------------------------------------------------------------------------
|
package/lib/concerns/__init__.py
CHANGED
|
@@ -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 .
|
|
11
|
-
from .coral_poker import CoralPoker
|
|
9
|
+
from .coral import CoralManager, CoralPoker
|
|
12
10
|
from .file_watcher import FileWatcher
|
|
13
|
-
from .
|
|
11
|
+
from .health import HealthChecker, ResourceMonitor, SessionKeepAlive
|
|
14
12
|
from .helpers import log, tmux_ok, warn
|
|
15
|
-
from .
|
|
16
|
-
from .
|
|
17
|
-
from .
|
|
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
|
-
"""
|
|
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')}")
|