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,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()
@@ -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
- r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
39
- if r.returncode == 0:
40
- subprocess.run(
41
- ["tmux", "send-keys", "-t", sess, "/exit", "Enter"],
42
- capture_output=True,
43
- )
44
- time.sleep(2)
45
- subprocess.run(["tmux", "kill-session", "-t", sess], capture_output=True)
46
- print(f"{target}: tmux session 已关闭")
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.execute(
18
- "INSERT INTO threads(id, title, author) VALUES (?, ?, ?)",
19
- (tid, title, name),
20
- )
21
- db.execute(
22
- "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
23
- (now, name, f"[BBS] 新帖「{title}」({tid})"),
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, helpers, and .md file sync."""
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
- Adds .md file sync helpers on top of the BaseDB interface.
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 == 0:
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
- rows = self.query(
190
- "SELECT '- [' || m.ts || '] **' || m.sender || '**: ' || substr(m.body, 1, 60) "
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
- try:
199
- text = sf.read_text()
200
- except OSError:
201
- return
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
- sessions = self.query("SELECT name FROM sessions WHERE name != ?", (sender,), c=c)
245
- for (target,) in sessions:
246
- self.execute(
247
- "INSERT INTO inbox(session, message_id) VALUES (?, ?)",
248
- (target, msg_id),
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
- for (target,) in sessions:
252
- self.sync_inbox_to_file(target, c=c)
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")
@@ -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
- return json.loads(PUBKEYS_FILE.read_text())
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 Exception:
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
- placeholders = ",".join("?" * len(decrypted_ids))
118
- db.execute(f"UPDATE mailbox SET read=1 WHERE id IN ({placeholders})", tuple(decrypted_ids))
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 Exception:
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}]")