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/bin/sync-version
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""sync-version — sync VERSION to package.json and pyproject.toml.
|
|
3
|
+
|
|
4
|
+
Reads the canonical version from VERSION file and updates:
|
|
5
|
+
- package.json: "version" field
|
|
6
|
+
- pyproject.toml: version field (PEP 440 format: X.Y.Z.dev0)
|
|
7
|
+
Also syncs the license from pyproject.toml to package.json.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
./bin/sync-version # sync + report
|
|
11
|
+
./bin/sync-version --check # check consistency only (exit 1 if drift)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_version() -> str:
|
|
23
|
+
return (ROOT / "VERSION").read_text().strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_pyproject() -> tuple[str, str]:
|
|
27
|
+
"""Return (version, license) from pyproject.toml."""
|
|
28
|
+
text = (ROOT / "pyproject.toml").read_text()
|
|
29
|
+
ver_m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
|
30
|
+
lic_m = re.search(r'^license\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
|
31
|
+
return (ver_m.group(1) if ver_m else ""), (lic_m.group(1) if lic_m else "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_package_json() -> tuple[str, str]:
|
|
35
|
+
"""Return (version, license) from package.json."""
|
|
36
|
+
data = json.loads((ROOT / "package.json").read_text())
|
|
37
|
+
return data.get("version", ""), data.get("license", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonical_to_pep440(ver: str) -> str:
|
|
41
|
+
"""Convert '0.5.1-dev' to '0.5.1.dev0' (PEP 440)."""
|
|
42
|
+
return re.sub(r"-dev$", ".dev0", ver)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pep440_to_npm(ver: str) -> str:
|
|
46
|
+
"""Convert '0.5.1.dev0' to '0.5.1-dev'."""
|
|
47
|
+
return re.sub(r"\.dev\d+$", "-dev", ver)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check() -> list[str]:
|
|
51
|
+
"""Return list of inconsistency descriptions. Empty = all good."""
|
|
52
|
+
ver = read_version()
|
|
53
|
+
pep_ver, pep_lic = read_pyproject()
|
|
54
|
+
npm_ver, npm_lic = read_package_json()
|
|
55
|
+
|
|
56
|
+
expected_pep = canonical_to_pep440(ver)
|
|
57
|
+
errors = []
|
|
58
|
+
|
|
59
|
+
if pep_ver != expected_pep:
|
|
60
|
+
errors.append(f"pyproject.toml version '{pep_ver}' != expected '{expected_pep}' (from VERSION '{ver}')")
|
|
61
|
+
if npm_ver != ver:
|
|
62
|
+
errors.append(f"package.json version '{npm_ver}' != VERSION '{ver}'")
|
|
63
|
+
if pep_lic and npm_lic != pep_lic:
|
|
64
|
+
errors.append(f"package.json license '{npm_lic}' != pyproject.toml license '{pep_lic}'")
|
|
65
|
+
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sync() -> None:
|
|
70
|
+
ver = read_version()
|
|
71
|
+
_, pep_lic = read_pyproject()
|
|
72
|
+
|
|
73
|
+
# Sync package.json
|
|
74
|
+
pkg_path = ROOT / "package.json"
|
|
75
|
+
pkg = json.loads(pkg_path.read_text())
|
|
76
|
+
changed = False
|
|
77
|
+
if pkg.get("version") != ver:
|
|
78
|
+
print(f"package.json version: '{pkg['version']}' -> '{ver}'")
|
|
79
|
+
pkg["version"] = ver
|
|
80
|
+
changed = True
|
|
81
|
+
if pep_lic and pkg.get("license") != pep_lic:
|
|
82
|
+
print(f"package.json license: '{pkg.get('license')}' -> '{pep_lic}'")
|
|
83
|
+
pkg["license"] = pep_lic
|
|
84
|
+
changed = True
|
|
85
|
+
if changed:
|
|
86
|
+
pkg_path.write_text(json.dumps(pkg, indent=2) + "\n")
|
|
87
|
+
else:
|
|
88
|
+
print("package.json: already in sync")
|
|
89
|
+
|
|
90
|
+
# Sync pyproject.toml version
|
|
91
|
+
pyp_path = ROOT / "pyproject.toml"
|
|
92
|
+
text = pyp_path.read_text()
|
|
93
|
+
expected_pep = canonical_to_pep440(ver)
|
|
94
|
+
new_text = re.sub(
|
|
95
|
+
r'^(version\s*=\s*")[^"]+"',
|
|
96
|
+
rf'\g<1>{expected_pep}"',
|
|
97
|
+
text,
|
|
98
|
+
count=1,
|
|
99
|
+
flags=re.MULTILINE,
|
|
100
|
+
)
|
|
101
|
+
if new_text != text:
|
|
102
|
+
pep_ver, _ = read_pyproject()
|
|
103
|
+
print(f"pyproject.toml version: '{pep_ver}' -> '{expected_pep}'")
|
|
104
|
+
pyp_path.write_text(new_text)
|
|
105
|
+
else:
|
|
106
|
+
print("pyproject.toml: already in sync")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
if "--check" in sys.argv:
|
|
111
|
+
errors = check()
|
|
112
|
+
if errors:
|
|
113
|
+
print("VERSION DRIFT DETECTED:")
|
|
114
|
+
for e in errors:
|
|
115
|
+
print(f" - {e}")
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
print("OK all versions and licenses consistent")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
sync()
|
|
121
|
+
errors = check()
|
|
122
|
+
if errors:
|
|
123
|
+
print("\nWARNING: still inconsistent after sync:")
|
|
124
|
+
for e in errors:
|
|
125
|
+
print(f" - {e}")
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
print("\nOK all synced")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
package/lib/board_admin.py
CHANGED
|
@@ -4,9 +4,12 @@ import subprocess
|
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
6
|
from lib.board_db import BoardDB, ts
|
|
7
|
+
from lib.common import validate_identity
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
11
|
+
assert db.env is not None
|
|
12
|
+
validate_identity(db, identity)
|
|
10
13
|
name = identity.lower()
|
|
11
14
|
if not args:
|
|
12
15
|
print("Usage: ./board --as <name> suspend <session>")
|
|
@@ -35,15 +38,19 @@ def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
35
38
|
|
|
36
39
|
prefix = db.env.prefix
|
|
37
40
|
sess = f"{prefix}-{target}"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
try:
|
|
42
|
+
r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True, timeout=5)
|
|
43
|
+
if r.returncode == 0:
|
|
44
|
+
subprocess.run(
|
|
45
|
+
["tmux", "send-keys", "-t", sess, "/exit", "Enter"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
timeout=5,
|
|
48
|
+
)
|
|
49
|
+
time.sleep(2)
|
|
50
|
+
subprocess.run(["tmux", "kill-session", "-t", sess], capture_output=True, timeout=5)
|
|
51
|
+
print(f"{target}: tmux session 已关闭")
|
|
52
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
53
|
+
pass
|
|
47
54
|
|
|
48
55
|
now = ts()
|
|
49
56
|
db.execute(
|
|
@@ -53,6 +60,8 @@ def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
def cmd_resume(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
63
|
+
assert db.env is not None
|
|
64
|
+
validate_identity(db, identity)
|
|
56
65
|
name = identity.lower()
|
|
57
66
|
if not args:
|
|
58
67
|
print("Usage: ./board --as <name> resume <session>")
|
|
@@ -80,6 +89,7 @@ def cmd_resume(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
80
89
|
|
|
81
90
|
|
|
82
91
|
def cmd_kudos(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
92
|
+
validate_identity(db, identity)
|
|
83
93
|
name = identity.lower()
|
|
84
94
|
if len(args) < 2:
|
|
85
95
|
print("Usage: ./board --as <name> kudos <target> <reason> [--evidence <commit/link>]")
|
package/lib/board_bbs.py
CHANGED
|
@@ -3,31 +3,43 @@
|
|
|
3
3
|
import hashlib
|
|
4
4
|
|
|
5
5
|
from lib.board_db import BoardDB, ts
|
|
6
|
+
from lib.common import validate_identity
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def cmd_post(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
10
|
+
validate_identity(db, identity)
|
|
9
11
|
name = identity.lower()
|
|
10
12
|
if len(args) < 2:
|
|
11
13
|
print("Usage: ./board --as <name> post <标题> <内容>")
|
|
12
14
|
raise SystemExit(1)
|
|
13
15
|
title = args[0]
|
|
16
|
+
body = " ".join(args[1:])
|
|
14
17
|
now = ts()
|
|
15
18
|
tid = hashlib.sha256(f"{title}{now}{identity}".encode()).hexdigest()[:6]
|
|
16
19
|
|
|
17
|
-
db.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
(
|
|
24
|
-
|
|
20
|
+
with db.conn() as c:
|
|
21
|
+
db.execute(
|
|
22
|
+
"INSERT INTO threads(id, title, author) VALUES (?, ?, ?)",
|
|
23
|
+
(tid, title, name),
|
|
24
|
+
c=c,
|
|
25
|
+
)
|
|
26
|
+
db.execute(
|
|
27
|
+
"INSERT INTO thread_replies(thread_id, author, body) VALUES (?, ?, ?)",
|
|
28
|
+
(tid, name, body),
|
|
29
|
+
c=c,
|
|
30
|
+
)
|
|
31
|
+
db.execute(
|
|
32
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
|
|
33
|
+
(now, name, f"[BBS] 新帖「{title}」({tid})"),
|
|
34
|
+
c=c,
|
|
35
|
+
)
|
|
25
36
|
print(f"OK 帖子已创建: {tid}")
|
|
26
37
|
print(f" 标题: {title}")
|
|
27
38
|
print(f" 查看: ./board --as <name> thread {tid}")
|
|
28
39
|
|
|
29
40
|
|
|
30
41
|
def cmd_reply(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
42
|
+
validate_identity(db, identity)
|
|
31
43
|
name = identity.lower()
|
|
32
44
|
if len(args) < 2:
|
|
33
45
|
print("Usage: ./board --as <name> reply <帖子ID> <内容>")
|
|
@@ -70,6 +82,9 @@ def cmd_thread(db: BoardDB, args: list[str]) -> None:
|
|
|
70
82
|
raise SystemExit(1)
|
|
71
83
|
|
|
72
84
|
row = db.query_one("SELECT title, author, created_at FROM threads WHERE id=?", (full_tid,))
|
|
85
|
+
if not row:
|
|
86
|
+
print(f"ERROR: 帖子 {full_tid} 数据异常")
|
|
87
|
+
raise SystemExit(1)
|
|
73
88
|
title, author, created = row
|
|
74
89
|
print(f"# {title}")
|
|
75
90
|
print(f"> @{author} — {created}\n")
|
package/lib/board_bug.py
CHANGED
|
@@ -4,10 +4,11 @@ import time
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
|
|
6
6
|
from lib.board_db import BoardDB, ts
|
|
7
|
-
from lib.common import is_terminal_bug_status
|
|
7
|
+
from lib.common import is_terminal_bug_status, validate_identity
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def cmd_bug(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
11
|
+
validate_identity(db, identity)
|
|
11
12
|
subcmd = args[0] if args else "list"
|
|
12
13
|
rest = args[1:] if len(args) > 1 else []
|
|
13
14
|
dispatch = {
|
package/lib/board_db.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""board_db — DB connection
|
|
1
|
+
"""board_db — DB connection and helpers."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -25,7 +25,7 @@ class BoardDB(BaseDB):
|
|
|
25
25
|
"""Unified SQLite wrapper — new connection per call, no pooling.
|
|
26
26
|
|
|
27
27
|
Accepts either a ClaudesEnv (production) or a bare Path/str (tests, lightweight callers).
|
|
28
|
-
|
|
28
|
+
SQLite is the single source of truth; no .md file sync.
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
def __init__(self, env_or_path: ClaudesEnv | Path | str):
|
|
@@ -41,6 +41,10 @@ class BoardDB(BaseDB):
|
|
|
41
41
|
self.env = None
|
|
42
42
|
self.db_path = Path(env_or_path)
|
|
43
43
|
|
|
44
|
+
def require_env(self) -> ClaudesEnv:
|
|
45
|
+
assert self.env is not None, "BoardDB created without ClaudesEnv — this command requires a full environment"
|
|
46
|
+
return self.env
|
|
47
|
+
|
|
44
48
|
def _auto_migrate(self) -> None:
|
|
45
49
|
"""Apply pending schema migrations on first load (idempotent).
|
|
46
50
|
|
|
@@ -92,164 +96,51 @@ class BoardDB(BaseDB):
|
|
|
92
96
|
|
|
93
97
|
def execute(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> int:
|
|
94
98
|
if c is not None:
|
|
95
|
-
return c.execute(sql, params).lastrowid
|
|
99
|
+
return c.execute(sql, params).lastrowid or 0
|
|
96
100
|
with self.conn() as conn:
|
|
97
101
|
cur = conn.execute(sql, params)
|
|
98
|
-
return cur.lastrowid
|
|
102
|
+
return cur.lastrowid or 0
|
|
99
103
|
|
|
100
104
|
def execute_changes(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> int:
|
|
101
105
|
if c is not None:
|
|
102
106
|
c.execute(sql, params)
|
|
103
|
-
return c.execute("SELECT changes()").fetchone()[0]
|
|
107
|
+
return int(c.execute("SELECT changes()").fetchone()[0])
|
|
104
108
|
with self.conn() as conn:
|
|
105
109
|
conn.execute(sql, params)
|
|
106
|
-
return conn.execute("SELECT changes()").fetchone()[0]
|
|
110
|
+
return int(conn.execute("SELECT changes()").fetchone()[0])
|
|
107
111
|
|
|
108
112
|
def ensure_session(self, name: str, *, c: sqlite3.Connection | None = None) -> None:
|
|
109
113
|
n = name.lower()
|
|
110
114
|
existing = self.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (n,), c=c)
|
|
111
|
-
if existing
|
|
112
|
-
self.execute("INSERT INTO sessions(name) VALUES (?)", (n,), c=c)
|
|
113
|
-
|
|
114
|
-
# --- .md file sync ---
|
|
115
|
-
#
|
|
116
|
-
# All writes use temp-file + atomic rename to avoid corruption on
|
|
117
|
-
# crash mid-write. Section detection is strict: ## heading must be
|
|
118
|
-
# followed by space or end-of-line (not e.g. "## @收件箱-extra").
|
|
119
|
-
|
|
120
|
-
INBOX_HEADINGS: tuple[str, ...] = ("## @inbox", "## @收件箱")
|
|
121
|
-
TASK_HEADINGS: tuple[str, ...] = ("## Current task", "## 当前任务")
|
|
122
|
-
|
|
123
|
-
@staticmethod
|
|
124
|
-
def _replace_section(
|
|
125
|
-
text: str,
|
|
126
|
-
headings: tuple[str, ...],
|
|
127
|
-
replacement: str | None,
|
|
128
|
-
) -> str | None:
|
|
129
|
-
"""Replace or remove the content of a named Markdown section.
|
|
130
|
-
|
|
131
|
-
If *replacement* is None, the section is removed. Returns the new
|
|
132
|
-
text, or None if the section was not found and no append is needed.
|
|
133
|
-
|
|
134
|
-
Heading detection: a line is a section boundary if it starts with "## "
|
|
135
|
-
(after stripping leading whitespace). Target headings are matched
|
|
136
|
-
case-insensitively against the normalized prefix.
|
|
137
|
-
"""
|
|
138
|
-
lines = text.split("\n")
|
|
139
|
-
out: list[str] = []
|
|
140
|
-
in_section = False
|
|
141
|
-
found = False
|
|
142
|
-
|
|
143
|
-
normalized_headings = tuple(h.strip().lower() for h in headings)
|
|
144
|
-
|
|
145
|
-
for line in lines:
|
|
146
|
-
stripped = line.strip().lower()
|
|
147
|
-
if any(stripped == h or stripped.startswith(h + " ") for h in normalized_headings):
|
|
148
|
-
found = True
|
|
149
|
-
in_section = True
|
|
150
|
-
if replacement is not None:
|
|
151
|
-
out.append(line)
|
|
152
|
-
out.append(replacement)
|
|
153
|
-
continue
|
|
154
|
-
if in_section and stripped.startswith("## "):
|
|
155
|
-
in_section = False
|
|
156
|
-
out.append("")
|
|
157
|
-
out.append(line)
|
|
158
|
-
continue
|
|
159
|
-
if in_section:
|
|
160
|
-
continue
|
|
161
|
-
out.append(line)
|
|
162
|
-
|
|
163
|
-
if not found:
|
|
164
|
-
return None
|
|
165
|
-
return "\n".join(out)
|
|
166
|
-
|
|
167
|
-
def _atomic_write(self, path: Path, content: str) -> None:
|
|
168
|
-
"""Write *content* to *path* atomically (temp + rename)."""
|
|
169
|
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
170
|
-
try:
|
|
171
|
-
tmp.write_text(content)
|
|
172
|
-
tmp.replace(path)
|
|
173
|
-
except OSError:
|
|
174
|
-
pass
|
|
175
|
-
|
|
176
|
-
# ── inbox sync ──
|
|
177
|
-
|
|
178
|
-
def sync_inbox_to_file(self, target: str, *, c: sqlite3.Connection | None = None) -> None:
|
|
179
|
-
"""Sync unread inbox messages to {target}.md file.
|
|
180
|
-
|
|
181
|
-
Accepts an optional *c* to use the caller's connection (so that
|
|
182
|
-
uncommitted inbox rows are visible within a transaction).
|
|
183
|
-
"""
|
|
184
|
-
if not self.env:
|
|
185
|
-
return
|
|
186
|
-
sf = self.env.sessions_dir / f"{target}.md"
|
|
187
|
-
if not sf.exists():
|
|
115
|
+
if existing:
|
|
188
116
|
return
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"FROM inbox i JOIN messages m ON i.message_id=m.id "
|
|
192
|
-
"WHERE i.session=? AND i.read=0 ORDER BY m.ts",
|
|
193
|
-
(target,),
|
|
194
|
-
c=c,
|
|
195
|
-
)
|
|
196
|
-
inbox_lines = "\n".join(r[0] for r in rows) if rows else ""
|
|
117
|
+
if self.env is not None:
|
|
118
|
+
from lib.common import PRIVILEGED_ROLES
|
|
197
119
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
result = self._replace_section(text, self.INBOX_HEADINGS, inbox_lines or None)
|
|
204
|
-
if result is not None:
|
|
205
|
-
self._atomic_write(sf, result)
|
|
206
|
-
elif inbox_lines:
|
|
207
|
-
heading = self.INBOX_HEADINGS[1] # canonical: Chinese heading
|
|
208
|
-
self._atomic_write(sf, text.rstrip("\n") + f"\n\n{heading}\n{inbox_lines}\n")
|
|
209
|
-
|
|
210
|
-
def clear_inbox_file(self, target: str) -> None:
|
|
211
|
-
if not self.env:
|
|
212
|
-
return
|
|
213
|
-
sf = self.env.sessions_dir / f"{target}.md"
|
|
214
|
-
if not sf.exists():
|
|
215
|
-
return
|
|
216
|
-
try:
|
|
217
|
-
text = sf.read_text()
|
|
218
|
-
except OSError:
|
|
219
|
-
return
|
|
220
|
-
result = self._replace_section(text, self.INBOX_HEADINGS, None)
|
|
221
|
-
if result is not None:
|
|
222
|
-
self._atomic_write(sf, result)
|
|
223
|
-
|
|
224
|
-
# ── status sync ──
|
|
225
|
-
|
|
226
|
-
def sync_status_to_file(self, target: str, status: str) -> None:
|
|
227
|
-
if not self.env:
|
|
228
|
-
return
|
|
229
|
-
sf = self.env.sessions_dir / f"{target}.md"
|
|
230
|
-
if not sf.exists():
|
|
231
|
-
return
|
|
232
|
-
try:
|
|
233
|
-
text = sf.read_text()
|
|
234
|
-
except OSError:
|
|
235
|
-
return
|
|
236
|
-
result = self._replace_section(text, self.TASK_HEADINGS, status)
|
|
237
|
-
if result is not None:
|
|
238
|
-
self._atomic_write(sf, result)
|
|
120
|
+
allowed = {s.lower() for s in self.env.sessions} | {"all"} | PRIVILEGED_ROLES
|
|
121
|
+
if n not in allowed:
|
|
122
|
+
print(f"ERROR: '{n}' is not a registered session")
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
self.execute("INSERT INTO sessions(name) VALUES (?)", (n,), c=c)
|
|
239
125
|
|
|
240
126
|
def deliver_to_inbox(
|
|
241
127
|
self, sender: str, recipient: str, msg_id: int, *, c: sqlite3.Connection | None = None
|
|
242
128
|
) -> None:
|
|
243
129
|
if recipient == "all":
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"INSERT INTO inbox(session, message_id)
|
|
248
|
-
(
|
|
249
|
-
c=c,
|
|
130
|
+
|
|
131
|
+
def _do(conn: sqlite3.Connection) -> list[str]:
|
|
132
|
+
conn.execute(
|
|
133
|
+
"INSERT INTO inbox(session, message_id) SELECT name, ? FROM sessions WHERE name != ?",
|
|
134
|
+
(msg_id, sender),
|
|
250
135
|
)
|
|
251
|
-
|
|
252
|
-
|
|
136
|
+
return [r[0] for r in conn.execute("SELECT name FROM sessions WHERE name != ?", (sender,)).fetchall()]
|
|
137
|
+
|
|
138
|
+
if c is not None:
|
|
139
|
+
targets = _do(c)
|
|
140
|
+
else:
|
|
141
|
+
with self.conn() as conn:
|
|
142
|
+
targets = _do(conn)
|
|
143
|
+
for target in targets:
|
|
253
144
|
inbox_delivered.emit(target)
|
|
254
145
|
else:
|
|
255
146
|
self.ensure_session(recipient, c=c)
|
|
@@ -258,5 +149,4 @@ class BoardDB(BaseDB):
|
|
|
258
149
|
(recipient, msg_id),
|
|
259
150
|
c=c,
|
|
260
151
|
)
|
|
261
|
-
self.sync_inbox_to_file(recipient, c=c)
|
|
262
152
|
inbox_delivered.emit(recipient)
|
package/lib/board_lock.py
CHANGED
|
@@ -4,6 +4,7 @@ import subprocess
|
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
6
|
from lib.board_db import BoardDB, ts
|
|
7
|
+
from lib.common import validate_identity
|
|
7
8
|
|
|
8
9
|
GIT_LOCK_TTL = 60
|
|
9
10
|
|
|
@@ -14,6 +15,7 @@ def _cleanup_stale(db: BoardDB) -> None:
|
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def cmd_git_lock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
18
|
+
validate_identity(db, identity)
|
|
17
19
|
name = identity.lower()
|
|
18
20
|
reason = " ".join(args) if args else "git operation"
|
|
19
21
|
|
|
@@ -50,6 +52,8 @@ def cmd_git_lock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
55
|
+
validate_identity(db, identity)
|
|
56
|
+
assert db.env is not None
|
|
53
57
|
name = identity.lower()
|
|
54
58
|
force = "--force" in args
|
|
55
59
|
|
|
@@ -72,7 +76,6 @@ def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
72
76
|
(now, holder, f"[GIT-LOCK] {name} force-released your git lock"),
|
|
73
77
|
)
|
|
74
78
|
db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (holder, msg_id))
|
|
75
|
-
db.sync_inbox_to_file(holder)
|
|
76
79
|
|
|
77
80
|
db.execute("DELETE FROM git_locks WHERE id=1")
|
|
78
81
|
print("OK git-lock released")
|
|
@@ -91,6 +94,7 @@ def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
91
94
|
|
|
92
95
|
|
|
93
96
|
def cmd_git_lock_status(db: BoardDB) -> None:
|
|
97
|
+
assert db.env is not None
|
|
94
98
|
_cleanup_stale(db)
|
|
95
99
|
|
|
96
100
|
row = db.query_one("SELECT session, reason, acquired_at, expires_at FROM git_locks WHERE id=1")
|
package/lib/board_mailbox.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""board_mailbox — encrypted async messaging between registered agents."""
|
|
2
2
|
|
|
3
|
+
import binascii
|
|
3
4
|
import json
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
7
|
+
from cryptography.exceptions import InvalidTag
|
|
8
|
+
|
|
6
9
|
from lib.board_db import BoardDB, ts
|
|
7
10
|
from lib.crypto import (
|
|
8
11
|
generate_keypair,
|
|
@@ -19,12 +22,14 @@ PUBKEYS_FILE = REGISTRY_DIR / "pubkeys.json"
|
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
def _keys_dir(db: BoardDB) -> Path:
|
|
25
|
+
assert db.env is not None
|
|
22
26
|
return db.env.claudes_dir / "keys"
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
def _load_pubkeys() -> dict[str, str]:
|
|
26
30
|
if PUBKEYS_FILE.exists():
|
|
27
|
-
|
|
31
|
+
data: dict[str, str] = json.loads(PUBKEYS_FILE.read_text())
|
|
32
|
+
return data
|
|
28
33
|
return {}
|
|
29
34
|
|
|
30
35
|
|
|
@@ -66,7 +71,11 @@ def cmd_seal(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
66
71
|
raise SystemExit(1)
|
|
67
72
|
|
|
68
73
|
recipient = args[0].lower()
|
|
69
|
-
plaintext = " ".join(args[1:])
|
|
74
|
+
plaintext = " ".join(args[1:]).strip()
|
|
75
|
+
|
|
76
|
+
if not plaintext:
|
|
77
|
+
print("ERROR: 消息不能为空")
|
|
78
|
+
raise SystemExit(1)
|
|
70
79
|
|
|
71
80
|
recipient_pubkey_hex = _find_pubkey(recipient)
|
|
72
81
|
if not recipient_pubkey_hex:
|
|
@@ -110,12 +119,13 @@ def cmd_unseal(db: BoardDB, identity: str) -> None:
|
|
|
110
119
|
plaintext = unseal_b64(encrypted_body, private)
|
|
111
120
|
print(f" [{msg_ts}] **{sender}**: {plaintext}")
|
|
112
121
|
decrypted_ids.append(msg_id)
|
|
113
|
-
except
|
|
114
|
-
print(f" [{msg_ts}] **{sender}**: [解密失败 —
|
|
122
|
+
except (InvalidTag, ValueError, binascii.Error, UnicodeDecodeError) as e:
|
|
123
|
+
print(f" [{msg_ts}] **{sender}**: [解密失败 — {type(e).__name__}: {e}]")
|
|
115
124
|
|
|
116
125
|
if decrypted_ids:
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
with db.conn() as c:
|
|
127
|
+
for mid in decrypted_ids:
|
|
128
|
+
db.execute("UPDATE mailbox SET read=1 WHERE id=?", (mid,), c=c)
|
|
119
129
|
print(f"\n已标记 {len(decrypted_ids)} 条为已读")
|
|
120
130
|
|
|
121
131
|
|
|
@@ -141,5 +151,5 @@ def cmd_mailbox_log(db: BoardDB, identity: str) -> None:
|
|
|
141
151
|
try:
|
|
142
152
|
plaintext = unseal_b64(encrypted_body, private)
|
|
143
153
|
print(f" [{msg_ts}] {sender}: {plaintext}")
|
|
144
|
-
except
|
|
145
|
-
print(f" [{msg_ts}] {sender}: [无法解密]")
|
|
154
|
+
except (InvalidTag, ValueError, binascii.Error, UnicodeDecodeError) as e:
|
|
155
|
+
print(f" [{msg_ts}] {sender}: [无法解密 — {type(e).__name__}: {e}]")
|