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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""SessionKeepAlive — detect dead dev sessions."""
|
|
2
|
+
|
|
3
|
+
from lib.common import is_suspended
|
|
4
|
+
|
|
5
|
+
from .base import Concern
|
|
6
|
+
from .config import DispatcherConfig
|
|
7
|
+
from .helpers import get_dev_sessions, is_claude_running, log, tmux_ok
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionKeepAlive(Concern):
|
|
11
|
+
interval = 5
|
|
12
|
+
|
|
13
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.cfg = cfg
|
|
16
|
+
|
|
17
|
+
def tick(self, now: int) -> None:
|
|
18
|
+
for name in get_dev_sessions(self.cfg):
|
|
19
|
+
if is_suspended(name, self.cfg.suspended_file):
|
|
20
|
+
continue
|
|
21
|
+
sess = f"{self.cfg.prefix}-{name}"
|
|
22
|
+
if tmux_ok("has-session", "-t", sess) and not is_claude_running(sess):
|
|
23
|
+
log(f"{name}: agent exited, NOT restarting (idle policy)")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""TimeAnnouncer — hourly/daily announcements."""
|
|
2
|
+
|
|
3
|
+
from .base import Concern
|
|
4
|
+
from .config import DispatcherConfig
|
|
5
|
+
from .helpers import board_send, log
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TimeAnnouncer(Concern):
|
|
9
|
+
interval = 30
|
|
10
|
+
|
|
11
|
+
def __init__(self, cfg: DispatcherConfig) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.cfg = cfg
|
|
14
|
+
self.last_hour = -1
|
|
15
|
+
|
|
16
|
+
def tick(self, now: int) -> None:
|
|
17
|
+
from datetime import datetime as dt
|
|
18
|
+
|
|
19
|
+
d = dt.now()
|
|
20
|
+
if d.minute != 0 or d.hour == self.last_hour:
|
|
21
|
+
return
|
|
22
|
+
self.last_hour = d.hour
|
|
23
|
+
ts = d.strftime("%Y-%m-%d %H:%M")
|
|
24
|
+
|
|
25
|
+
if d.hour == 9:
|
|
26
|
+
board_send(
|
|
27
|
+
self.cfg,
|
|
28
|
+
"All",
|
|
29
|
+
f"[Clock] {ts} ({d.strftime('%A')}) — 新一天。检查 KR 列表,确认优先级。",
|
|
30
|
+
)
|
|
31
|
+
log("Daily announcement sent")
|
|
32
|
+
else:
|
|
33
|
+
board_send(self.cfg, "All", f"[Clock] 现在是 {ts}。")
|
|
34
|
+
log(f"Hourly announcement: {d.hour}:00")
|
package/lib/crypto.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""crypto — X25519 sealed-box encryption for inter-agent messaging."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
9
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
10
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
11
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
12
|
+
Encoding,
|
|
13
|
+
NoEncryption,
|
|
14
|
+
PrivateFormat,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_HKDF_INFO = b"claudes-mailbox-v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_keypair() -> tuple[X25519PrivateKey, X25519PublicKey]:
|
|
21
|
+
private = X25519PrivateKey.generate()
|
|
22
|
+
return private, private.public_key()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def private_key_to_pem(key: X25519PrivateKey) -> bytes:
|
|
26
|
+
return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def private_key_from_pem(data: bytes) -> X25519PrivateKey:
|
|
30
|
+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
31
|
+
|
|
32
|
+
k = load_pem_private_key(data, password=None)
|
|
33
|
+
if not isinstance(k, X25519PrivateKey):
|
|
34
|
+
raise ValueError("not an X25519 private key")
|
|
35
|
+
return k
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def public_key_to_hex(key: X25519PublicKey) -> str:
|
|
39
|
+
return key.public_bytes_raw().hex()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def public_key_from_hex(h: str) -> X25519PublicKey:
|
|
43
|
+
return X25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _derive_key(shared_secret: bytes) -> bytes:
|
|
47
|
+
return HKDF(algorithm=SHA256(), length=32, salt=None, info=_HKDF_INFO).derive(shared_secret)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def seal(plaintext: bytes, recipient_pub: X25519PublicKey) -> bytes:
|
|
51
|
+
"""Encrypt with ephemeral X25519 + AESGCM. Returns ephemeral_pub(32) + nonce(12) + ciphertext."""
|
|
52
|
+
eph_priv, eph_pub = generate_keypair()
|
|
53
|
+
shared = eph_priv.exchange(recipient_pub)
|
|
54
|
+
aes_key = _derive_key(shared)
|
|
55
|
+
nonce = os.urandom(12)
|
|
56
|
+
ct = AESGCM(aes_key).encrypt(nonce, plaintext, None)
|
|
57
|
+
return eph_pub.public_bytes_raw() + nonce + ct
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unseal(blob: bytes, recipient_priv: X25519PrivateKey) -> bytes:
|
|
61
|
+
"""Decrypt a sealed message. Raises on tamper or wrong key."""
|
|
62
|
+
if len(blob) < 44:
|
|
63
|
+
raise ValueError("ciphertext too short")
|
|
64
|
+
eph_pub_bytes = blob[:32]
|
|
65
|
+
nonce = blob[32:44]
|
|
66
|
+
ct = blob[44:]
|
|
67
|
+
eph_pub = X25519PublicKey.from_public_bytes(eph_pub_bytes)
|
|
68
|
+
shared = recipient_priv.exchange(eph_pub)
|
|
69
|
+
aes_key = _derive_key(shared)
|
|
70
|
+
return AESGCM(aes_key).decrypt(nonce, ct, None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def seal_b64(plaintext: str, recipient_pub: X25519PublicKey) -> str:
|
|
74
|
+
return base64.b64encode(seal(plaintext.encode(), recipient_pub)).decode()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def unseal_b64(encoded: str, recipient_priv: X25519PrivateKey) -> str:
|
|
78
|
+
return unseal(base64.b64decode(encoded), recipient_priv).decode()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_keypair(keys_dir: Path, name: str, private: X25519PrivateKey, public: X25519PublicKey) -> None:
|
|
82
|
+
keys_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
(keys_dir / f"{name}.pem").write_bytes(private_key_to_pem(private))
|
|
84
|
+
(keys_dir / f"{name}.pem").chmod(0o600)
|
|
85
|
+
(keys_dir / f"{name}.pub").write_text(public_key_to_hex(public) + "\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_private_key(keys_dir: Path, name: str) -> X25519PrivateKey:
|
|
89
|
+
pem_path = keys_dir / f"{name}.pem"
|
|
90
|
+
if not pem_path.exists():
|
|
91
|
+
raise FileNotFoundError(f"私钥不存在: {pem_path} (先运行 keygen)")
|
|
92
|
+
return private_key_from_pem(pem_path.read_bytes())
|
package/lib/health.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""health.py -- Session health report with colored output.
|
|
3
|
+
|
|
4
|
+
Shows restart count, idle status, uptime for each session.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
./lib/health.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Try to import common; fall back gracefully for standalone use
|
|
17
|
+
try:
|
|
18
|
+
from lib.common import ClaudesEnv, date_to_epoch
|
|
19
|
+
except ImportError:
|
|
20
|
+
# Allow running from project root
|
|
21
|
+
_here = Path(__file__).resolve().parent.parent
|
|
22
|
+
sys.path.insert(0, str(_here))
|
|
23
|
+
from lib.common import ClaudesEnv, date_to_epoch
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# ANSI colors
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
G = "\033[0;32m"
|
|
30
|
+
R = "\033[0;31m"
|
|
31
|
+
Y = "\033[1;33m"
|
|
32
|
+
D = "\033[2m"
|
|
33
|
+
NC = "\033[0m"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tmux(*args: str) -> str | None:
|
|
42
|
+
"""Run a tmux command, return stdout or None on failure."""
|
|
43
|
+
try:
|
|
44
|
+
r = subprocess.run(
|
|
45
|
+
["tmux", *args],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
timeout=5,
|
|
49
|
+
)
|
|
50
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
51
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_sessions(prefix: str, dispatcher: str = "dispatcher", lead: str = "lead") -> list:
|
|
56
|
+
"""List dev session names from tmux (excludes dispatcher & lead)."""
|
|
57
|
+
raw = _tmux("list-sessions", "-F", "#{session_name}")
|
|
58
|
+
if raw is None:
|
|
59
|
+
return []
|
|
60
|
+
names = []
|
|
61
|
+
for line in sorted(raw.splitlines()):
|
|
62
|
+
if line.startswith(f"{prefix}-"):
|
|
63
|
+
name = line[len(prefix) + 1 :]
|
|
64
|
+
if name not in (dispatcher, lead):
|
|
65
|
+
names.append(name)
|
|
66
|
+
return names
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_claude_running(sess: str) -> bool:
|
|
70
|
+
"""Check if the pane's current command looks like an active Claude process."""
|
|
71
|
+
if _tmux("has-session", "-t", sess) is None:
|
|
72
|
+
return False
|
|
73
|
+
cmd = _tmux("list-panes", "-t", sess, "-F", "#{pane_current_command}")
|
|
74
|
+
if cmd is None:
|
|
75
|
+
return False
|
|
76
|
+
first = cmd.splitlines()[0] if cmd else ""
|
|
77
|
+
return first not in ("zsh", "bash", "sh", "-zsh", "-bash", "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def session_status(sess: str, idle_cache: Path) -> str:
|
|
81
|
+
"""Return status string: active, idle, exited, offline."""
|
|
82
|
+
if _tmux("has-session", "-t", sess) is None:
|
|
83
|
+
return "offline"
|
|
84
|
+
|
|
85
|
+
cmd = _tmux("list-panes", "-t", sess, "-F", "#{pane_current_command}")
|
|
86
|
+
first = (cmd or "").splitlines()[0] if cmd else ""
|
|
87
|
+
if first in ("zsh", "bash", "sh", "-zsh", "-bash", ""):
|
|
88
|
+
return "exited"
|
|
89
|
+
|
|
90
|
+
# Check idle cache
|
|
91
|
+
if idle_cache.exists():
|
|
92
|
+
for line in idle_cache.read_text().splitlines():
|
|
93
|
+
if line == f"{sess} idle":
|
|
94
|
+
return "idle"
|
|
95
|
+
return "active"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Main report
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_health_report() -> None:
|
|
104
|
+
env = ClaudesEnv.load()
|
|
105
|
+
prefix = env.prefix
|
|
106
|
+
log_dir = env.project_root / ".swarm-logs"
|
|
107
|
+
idle_cache = log_dir / "idle-cache"
|
|
108
|
+
now = int(time.time())
|
|
109
|
+
|
|
110
|
+
sessions = get_sessions(prefix)
|
|
111
|
+
|
|
112
|
+
print()
|
|
113
|
+
print(" Session\t\tStatus\t\tRestarts\tUptime\t\tAgent")
|
|
114
|
+
print(" -------\t\t------\t\t--------\t------\t\t-----")
|
|
115
|
+
|
|
116
|
+
total = 0
|
|
117
|
+
alive = 0
|
|
118
|
+
idle_count = 0
|
|
119
|
+
|
|
120
|
+
for name in sessions:
|
|
121
|
+
total += 1
|
|
122
|
+
sess = f"{prefix}-{name}"
|
|
123
|
+
|
|
124
|
+
status = session_status(sess, idle_cache)
|
|
125
|
+
if status == "idle":
|
|
126
|
+
idle_count += 1
|
|
127
|
+
alive += 1
|
|
128
|
+
elif status == "active":
|
|
129
|
+
alive += 1
|
|
130
|
+
|
|
131
|
+
# Restart count
|
|
132
|
+
restarts = 0
|
|
133
|
+
log_file = log_dir / f"{name}.log"
|
|
134
|
+
if log_file.exists():
|
|
135
|
+
restarts = sum(1 for _ in log_file.read_text().splitlines())
|
|
136
|
+
|
|
137
|
+
# Uptime + agent
|
|
138
|
+
uptime_str = "-"
|
|
139
|
+
agent = "?"
|
|
140
|
+
if log_file.exists():
|
|
141
|
+
lines = log_file.read_text().splitlines()
|
|
142
|
+
if lines:
|
|
143
|
+
last_line = lines[-1]
|
|
144
|
+
ts_match = re.search(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", last_line)
|
|
145
|
+
agent_match = re.search(r"agent: ([a-z]+)", last_line)
|
|
146
|
+
if agent_match:
|
|
147
|
+
agent = agent_match.group(1)
|
|
148
|
+
if ts_match:
|
|
149
|
+
start_epoch = date_to_epoch(ts_match.group(0))
|
|
150
|
+
if start_epoch > 0:
|
|
151
|
+
elapsed = now - start_epoch
|
|
152
|
+
hours = elapsed // 3600
|
|
153
|
+
mins = (elapsed % 3600) // 60
|
|
154
|
+
uptime_str = f"{hours}h{mins}m"
|
|
155
|
+
|
|
156
|
+
# Color status
|
|
157
|
+
if status == "active":
|
|
158
|
+
status_col = f"{G}active{NC}"
|
|
159
|
+
elif status == "idle":
|
|
160
|
+
status_col = f"{Y}idle{NC}"
|
|
161
|
+
elif status == "exited":
|
|
162
|
+
status_col = f"{R}exited{NC}"
|
|
163
|
+
else:
|
|
164
|
+
status_col = f"{D}offline{NC}"
|
|
165
|
+
|
|
166
|
+
# Color restarts
|
|
167
|
+
if restarts > 5:
|
|
168
|
+
restart_col = f"{R}{restarts}{NC}"
|
|
169
|
+
elif restarts > 2:
|
|
170
|
+
restart_col = f"{Y}{restarts}{NC}"
|
|
171
|
+
else:
|
|
172
|
+
restart_col = f"{G}{restarts}{NC}"
|
|
173
|
+
|
|
174
|
+
print(f" {name}\t\t{status_col}\t\t{restart_col}\t\t{uptime_str}\t\t{agent}")
|
|
175
|
+
|
|
176
|
+
print()
|
|
177
|
+
offline = total - alive
|
|
178
|
+
print(f" Total: {total} | Active: {G}{alive}{NC} | Idle: {Y}{idle_count}{NC} | Offline: {D}{offline}{NC}")
|
|
179
|
+
print()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main() -> None:
|
|
183
|
+
print_health_report()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
main()
|
package/lib/inject.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""inject.py -- Force-inject a message into another Claude Code session.
|
|
3
|
+
|
|
4
|
+
Auto-detects tmux or screen. Override with SWARM_MODE=tmux|screen.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
./lib/inject.py <target> <message>
|
|
8
|
+
./lib/inject.py all "everyone check inbox"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
# Try to import common; fall back gracefully for standalone use
|
|
17
|
+
try:
|
|
18
|
+
from lib.common import ClaudesEnv
|
|
19
|
+
except ImportError:
|
|
20
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
sys.path.insert(0, os.path.dirname(_here))
|
|
22
|
+
from lib.common import ClaudesEnv
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_mode(prefix: str) -> str:
|
|
26
|
+
"""Auto-detect session multiplexer: tmux, screen, or none."""
|
|
27
|
+
swarm_mode = os.environ.get("SWARM_MODE", "")
|
|
28
|
+
if swarm_mode:
|
|
29
|
+
return swarm_mode
|
|
30
|
+
|
|
31
|
+
# Check tmux
|
|
32
|
+
try:
|
|
33
|
+
r = subprocess.run(
|
|
34
|
+
["tmux", "list-sessions", "-F", "#{session_name}"],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=5,
|
|
38
|
+
)
|
|
39
|
+
if r.returncode == 0:
|
|
40
|
+
for line in r.stdout.splitlines():
|
|
41
|
+
if line.startswith(f"{prefix}-"):
|
|
42
|
+
return "tmux"
|
|
43
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# Check screen
|
|
47
|
+
try:
|
|
48
|
+
r = subprocess.run(
|
|
49
|
+
["screen", "-list"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=5,
|
|
53
|
+
)
|
|
54
|
+
if f".{prefix}-" in (r.stdout + r.stderr):
|
|
55
|
+
return "screen"
|
|
56
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
if shutil.which("tmux"):
|
|
60
|
+
return "tmux"
|
|
61
|
+
elif shutil.which("screen"):
|
|
62
|
+
return "screen"
|
|
63
|
+
return "none"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def send_tmux(prefix: str, name: str, message: str) -> bool:
|
|
67
|
+
"""Inject message into a tmux session. Returns True on success."""
|
|
68
|
+
sess = f"{prefix}-{name}"
|
|
69
|
+
try:
|
|
70
|
+
r = subprocess.run(
|
|
71
|
+
["tmux", "has-session", "-t", sess],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
timeout=5,
|
|
74
|
+
)
|
|
75
|
+
if r.returncode != 0:
|
|
76
|
+
print(f" {name}: not running")
|
|
77
|
+
return False
|
|
78
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
79
|
+
print(f" {name}: not running")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
oneline = message.replace("\n", " ")
|
|
83
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline], timeout=5)
|
|
84
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=5)
|
|
85
|
+
print(f" {name}: injected (tmux)")
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def send_screen(prefix: str, name: str, message: str) -> bool:
|
|
90
|
+
"""Inject message into a screen session. Returns True on success."""
|
|
91
|
+
sess = f"{prefix}-{name}"
|
|
92
|
+
try:
|
|
93
|
+
r = subprocess.run(
|
|
94
|
+
["screen", "-list"],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=5,
|
|
98
|
+
)
|
|
99
|
+
if f".{sess}" not in (r.stdout + r.stderr):
|
|
100
|
+
print(f" {name}: not running")
|
|
101
|
+
return False
|
|
102
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
103
|
+
print(f" {name}: not running")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
oneline = message.replace("\n", " ")
|
|
107
|
+
subprocess.run(
|
|
108
|
+
["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline],
|
|
109
|
+
timeout=5,
|
|
110
|
+
)
|
|
111
|
+
import time
|
|
112
|
+
|
|
113
|
+
time.sleep(0.3)
|
|
114
|
+
subprocess.run(
|
|
115
|
+
["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"],
|
|
116
|
+
timeout=5,
|
|
117
|
+
)
|
|
118
|
+
print(f" {name}: injected (screen)")
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def inject(target: str, message: str, prefix: str | None = None, sessions: list | None = None) -> None:
|
|
123
|
+
"""Inject a message to *target* (a session name or 'all')."""
|
|
124
|
+
if prefix is None or sessions is None:
|
|
125
|
+
env = ClaudesEnv.load()
|
|
126
|
+
prefix = prefix or env.prefix
|
|
127
|
+
sessions = sessions if sessions is not None else env.sessions
|
|
128
|
+
|
|
129
|
+
mode = detect_mode(prefix)
|
|
130
|
+
if mode == "none":
|
|
131
|
+
print("ERROR: neither tmux nor screen found", file=sys.stderr)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
send_fn = send_tmux if mode == "tmux" else send_screen
|
|
135
|
+
target_lower = target.lower()
|
|
136
|
+
|
|
137
|
+
if target_lower == "all":
|
|
138
|
+
print(f"Injecting to all ({mode}):")
|
|
139
|
+
for name in sessions:
|
|
140
|
+
send_fn(prefix, name, message)
|
|
141
|
+
else:
|
|
142
|
+
send_fn(prefix, target_lower, message)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
if len(sys.argv) < 3:
|
|
147
|
+
print(
|
|
148
|
+
"Usage: ./inject.py <target> <message>\n"
|
|
149
|
+
"\n"
|
|
150
|
+
" target: session name or 'all'\n"
|
|
151
|
+
"\n"
|
|
152
|
+
"Examples:\n"
|
|
153
|
+
' ./inject.py alice "what\'s blocking P0?"\n'
|
|
154
|
+
' ./inject.py all "everyone check inbox"',
|
|
155
|
+
)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
target = sys.argv[1]
|
|
159
|
+
message = " ".join(sys.argv[2:])
|
|
160
|
+
inject(target, message)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
package/lib/migrate.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""migrate — schema migration runner for cnb.
|
|
2
|
+
|
|
3
|
+
Tracks applied migrations in the `meta` table (key='schema_version').
|
|
4
|
+
Migrations live in the `migrations/` directory alongside schema.sql.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _migrations_dir(claudes_home: Path) -> Path:
|
|
13
|
+
d = claudes_home / "migrations"
|
|
14
|
+
if not d.is_dir():
|
|
15
|
+
print(f"FATAL: migrations directory not found: {d}", file=sys.stderr)
|
|
16
|
+
raise SystemExit(1)
|
|
17
|
+
return d
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _applied_versions(c: sqlite3.Connection) -> set[int]:
|
|
21
|
+
"""Return the set of already-applied migration version numbers."""
|
|
22
|
+
try:
|
|
23
|
+
row = c.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone()
|
|
24
|
+
if row:
|
|
25
|
+
return set(range(1, int(row[0]) + 1))
|
|
26
|
+
except sqlite3.OperationalError:
|
|
27
|
+
pass # meta table may not exist yet (first init)
|
|
28
|
+
return set()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _record_version(c: sqlite3.Connection, version: int) -> None:
|
|
32
|
+
c.execute(
|
|
33
|
+
"INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
|
|
34
|
+
(str(version),),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _discover_migrations(mdir: Path) -> list[tuple[int, Path]]:
|
|
39
|
+
"""Find migration files named NNN_description.sql, sorted by version."""
|
|
40
|
+
migs: list[tuple[int, Path]] = []
|
|
41
|
+
for f in mdir.glob("*.sql"):
|
|
42
|
+
prefix = f.name[:3]
|
|
43
|
+
if prefix.isdigit():
|
|
44
|
+
migs.append((int(prefix), f))
|
|
45
|
+
migs.sort()
|
|
46
|
+
return migs
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_migrations(db_path: Path, claudes_home: Path) -> int:
|
|
50
|
+
"""Apply all pending migrations. Returns number of migrations applied."""
|
|
51
|
+
mdir = _migrations_dir(claudes_home)
|
|
52
|
+
conn = sqlite3.connect(str(db_path))
|
|
53
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
54
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
55
|
+
|
|
56
|
+
applied = 0
|
|
57
|
+
try:
|
|
58
|
+
done = _applied_versions(conn)
|
|
59
|
+
all_migs = _discover_migrations(mdir)
|
|
60
|
+
|
|
61
|
+
for ver, path in all_migs:
|
|
62
|
+
if ver in done:
|
|
63
|
+
continue
|
|
64
|
+
sql = path.read_text()
|
|
65
|
+
print(f" Applying migration {path.name} ...", end=" ", flush=True)
|
|
66
|
+
conn.executescript(sql)
|
|
67
|
+
_record_version(conn, ver)
|
|
68
|
+
conn.commit()
|
|
69
|
+
print("OK")
|
|
70
|
+
applied += 1
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
conn.rollback()
|
|
74
|
+
print(f"\nFATAL: migration failed: {e}", file=sys.stderr)
|
|
75
|
+
raise SystemExit(1) from e
|
|
76
|
+
finally:
|
|
77
|
+
conn.close()
|
|
78
|
+
|
|
79
|
+
return applied
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# CLI
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
"""Run pending migrations. Called from init and doctor."""
|
|
88
|
+
# Resolve CLAUDES_HOME relative to this file
|
|
89
|
+
claudes_home = Path(__file__).resolve().parent.parent
|
|
90
|
+
db_path = claudes_home / ".claudes" / "board.db"
|
|
91
|
+
|
|
92
|
+
# Prefer the DB path from the current project
|
|
93
|
+
from pathlib import Path as _P
|
|
94
|
+
|
|
95
|
+
cwd_db = _P.cwd() / ".claudes" / "board.db"
|
|
96
|
+
if cwd_db.exists():
|
|
97
|
+
db_path = cwd_db
|
|
98
|
+
|
|
99
|
+
if not db_path.exists():
|
|
100
|
+
print("ERROR: board.db not found. Run: cnb init <sessions>", flush=True)
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
|
|
103
|
+
n = run_migrations(db_path, claudes_home)
|
|
104
|
+
if n > 0:
|
|
105
|
+
print(f"Applied {n} migration(s).")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|