claude-nb 0.4.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 (49) hide show
  1. package/Makefile +8 -2
  2. package/README.md +40 -56
  3. package/VERSION +1 -1
  4. package/bin/board +102 -34
  5. package/bin/cnb +59 -33
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +8 -8
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/sync-version +131 -0
  12. package/lib/board_admin.py +19 -9
  13. package/lib/board_bbs.py +23 -8
  14. package/lib/board_bug.py +2 -1
  15. package/lib/board_db.py +18 -6
  16. package/lib/board_lock.py +5 -0
  17. package/lib/board_mailbox.py +6 -4
  18. package/lib/board_msg.py +112 -24
  19. package/lib/board_pending.py +233 -0
  20. package/lib/board_pulse.py +14 -0
  21. package/lib/board_task.py +22 -10
  22. package/lib/board_tui.py +28 -20
  23. package/lib/board_view.py +60 -28
  24. package/lib/board_vote.py +9 -3
  25. package/lib/build_lock.py +7 -7
  26. package/lib/common.py +45 -3
  27. package/lib/concerns/__init__.py +4 -1
  28. package/lib/concerns/coral.py +1 -1
  29. package/lib/concerns/digest_scheduler.py +109 -0
  30. package/lib/concerns/file_watcher.py +73 -68
  31. package/lib/concerns/health.py +1 -1
  32. package/lib/concerns/notification_push.py +171 -0
  33. package/lib/concerns/notifications.py +58 -3
  34. package/lib/concerns/nudge_coordinator.py +148 -0
  35. package/lib/digest.py +62 -0
  36. package/lib/health.py +2 -2
  37. package/lib/inject.py +2 -2
  38. package/lib/monitor.py +8 -4
  39. package/lib/notification_config.py +101 -0
  40. package/lib/swarm.py +43 -35
  41. package/lib/swarm_backend.py +63 -29
  42. package/lib/theme_profiles.py +89 -0
  43. package/migrations/004_heartbeat.sql +1 -0
  44. package/migrations/005_notification_log.sql +12 -0
  45. package/migrations/006_pending_actions.sql +15 -0
  46. package/package.json +4 -3
  47. package/pyproject.toml +3 -2
  48. package/registry/README.md +9 -0
  49. package/schema.sql +29 -1
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
@@ -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,24 +96,32 @@ 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)
115
+ if existing:
116
+ return
117
+ if self.env is not None:
118
+ from lib.common import PRIVILEGED_ROLES
119
+
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)
113
125
 
114
126
  def deliver_to_inbox(
115
127
  self, sender: str, recipient: str, msg_id: int, *, c: sqlite3.Connection | None = None
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
 
@@ -90,6 +94,7 @@ def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
90
94
 
91
95
 
92
96
  def cmd_git_lock_status(db: BoardDB) -> None:
97
+ assert db.env is not None
93
98
  _cleanup_stale(db)
94
99
 
95
100
  row = db.query_one("SELECT session, reason, acquired_at, expires_at FROM git_locks WHERE id=1")
@@ -1,6 +1,6 @@
1
1
  """board_mailbox — encrypted async messaging between registered agents."""
2
2
 
3
- import base64
3
+ import binascii
4
4
  import json
5
5
  from pathlib import Path
6
6
 
@@ -22,12 +22,14 @@ PUBKEYS_FILE = REGISTRY_DIR / "pubkeys.json"
22
22
 
23
23
 
24
24
  def _keys_dir(db: BoardDB) -> Path:
25
+ assert db.env is not None
25
26
  return db.env.claudes_dir / "keys"
26
27
 
27
28
 
28
29
  def _load_pubkeys() -> dict[str, str]:
29
30
  if PUBKEYS_FILE.exists():
30
- return json.loads(PUBKEYS_FILE.read_text())
31
+ data: dict[str, str] = json.loads(PUBKEYS_FILE.read_text())
32
+ return data
31
33
  return {}
32
34
 
33
35
 
@@ -117,7 +119,7 @@ def cmd_unseal(db: BoardDB, identity: str) -> None:
117
119
  plaintext = unseal_b64(encrypted_body, private)
118
120
  print(f" [{msg_ts}] **{sender}**: {plaintext}")
119
121
  decrypted_ids.append(msg_id)
120
- except (InvalidTag, ValueError, base64.binascii.Error, UnicodeDecodeError) as e:
122
+ except (InvalidTag, ValueError, binascii.Error, UnicodeDecodeError) as e:
121
123
  print(f" [{msg_ts}] **{sender}**: [解密失败 — {type(e).__name__}: {e}]")
122
124
 
123
125
  if decrypted_ids:
@@ -149,5 +151,5 @@ def cmd_mailbox_log(db: BoardDB, identity: str) -> None:
149
151
  try:
150
152
  plaintext = unseal_b64(encrypted_body, private)
151
153
  print(f" [{msg_ts}] {sender}: {plaintext}")
152
- except (InvalidTag, ValueError, base64.binascii.Error, UnicodeDecodeError) as e:
154
+ except (InvalidTag, ValueError, binascii.Error, UnicodeDecodeError) as e:
153
155
  print(f" [{msg_ts}] {sender}: [无法解密 — {type(e).__name__}: {e}]")
package/lib/board_msg.py CHANGED
@@ -1,19 +1,86 @@
1
1
  """board_msg — send / inbox / ack / status / log commands."""
2
2
 
3
3
  import hashlib
4
+ import re
4
5
  import shutil
6
+ import subprocess
5
7
  from pathlib import Path
6
8
 
7
9
  from lib.board_db import BoardDB, ts
8
- from lib.common import parse_flags
10
+ from lib.common import parse_flags, validate_identity
9
11
 
10
12
 
11
13
  def _ack_marker_path(db: BoardDB, name: str) -> Path:
12
14
  """Path to file recording max message_id seen by last inbox call."""
15
+ assert db.env is not None
13
16
  return db.env.sessions_dir / f".{name}.ack_max_id"
14
17
 
15
18
 
19
+ def _is_idle(sess: str) -> bool:
20
+ """Check if a Claude Code session is idle at its prompt (not mid-response).
21
+
22
+ Claude Code's prompt layout puts the prompt marker a few lines above the bottom
23
+ (status bar, project name, permissions line are below it), so we
24
+ check the last ~8 lines rather than just the final line.
25
+ """
26
+ r = subprocess.run(
27
+ ["tmux", "capture-pane", "-t", sess, "-p", "-S", "-10"],
28
+ capture_output=True,
29
+ text=True,
30
+ timeout=3,
31
+ )
32
+ if r.returncode != 0:
33
+ return False
34
+ text = r.stdout.rstrip()
35
+ if not text:
36
+ return False
37
+ busy_indicators = ("Choreographing", "Seasoning", "Churned", "Gitifying", "Running", "ctrl+b", "thinking")
38
+ for line in reversed(text.splitlines()):
39
+ stripped = line.strip()
40
+ if not stripped:
41
+ continue
42
+ if any(ind in stripped for ind in busy_indicators):
43
+ return False
44
+ if "❯" in stripped or "Press up to edit" in stripped:
45
+ return True
46
+ break
47
+ return "❯" in text or "Press up to edit" in text
48
+
49
+
50
+ def _nudge_session(db: BoardDB, recipient: str) -> None:
51
+ """Inject a fixed prompt into the recipient's tmux session to check inbox.
52
+
53
+ Only nudges idle agents — busy agents will pick up messages via the
54
+ PostToolBatch hook. This avoids commands piling up in Claude Code's
55
+ message queue when injected mid-response.
56
+ """
57
+ assert db.env is not None
58
+ if recipient == "all":
59
+ sessions = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all'")]
60
+ else:
61
+ sessions = [recipient]
62
+ prefix = db.env.prefix
63
+ board = db.env.install_home / "bin" / "board"
64
+ for name in sessions:
65
+ if not re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
66
+ continue
67
+ sess = f"{prefix}-{name}"
68
+ try:
69
+ r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True, timeout=5)
70
+ if r.returncode != 0:
71
+ continue
72
+ if not _is_idle(sess):
73
+ continue
74
+ cmd = f"{board} --as {name} inbox"
75
+ subprocess.run(["tmux", "send-keys", "-t", sess, "-l", cmd], capture_output=True, timeout=5)
76
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], capture_output=True, timeout=5)
77
+ except (subprocess.TimeoutExpired, OSError):
78
+ continue
79
+
80
+
16
81
  def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
82
+ assert db.env is not None
83
+ validate_identity(db, identity)
17
84
  name = identity.lower()
18
85
  flags, send_args = parse_flags(args, value_flags={"attach": ["--attach", "-a"]})
19
86
  attach_file = flags.get("attach")
@@ -24,9 +91,8 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
24
91
 
25
92
  to = send_args[0].lower()
26
93
 
27
- if to != "all" and not db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (to,)):
28
- print(f"ERROR: 收件人 '{to}' 不存在")
29
- raise SystemExit(1)
94
+ if to != "all":
95
+ db.ensure_session(to)
30
96
 
31
97
  msg = " ".join(send_args[1:]) if len(send_args) > 1 else ""
32
98
 
@@ -38,7 +104,7 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
38
104
  stored_path = ""
39
105
  h = ""
40
106
  if attach_file:
41
- path = Path(attach_file)
107
+ path = Path(str(attach_file))
42
108
  if not path.is_file():
43
109
  print(f"ERROR: file not found: {attach_file}")
44
110
  raise SystemExit(1)
@@ -76,9 +142,14 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
76
142
  db.deliver_to_inbox(name, to, msg_id, c=c)
77
143
 
78
144
  print("OK sent")
145
+ if attach_ref:
146
+ print(f" 附件已存储: {stored_path}")
147
+
148
+ _nudge_session(db, to)
79
149
 
80
150
 
81
151
  def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
152
+ validate_identity(db, identity)
82
153
  name = identity.lower()
83
154
  if not args:
84
155
  print("Usage: ./board --as <name> status <description>")
@@ -90,34 +161,51 @@ def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
90
161
  "UPDATE sessions SET status=?, updated_at=? WHERE name=?",
91
162
  (full_status, now, name),
92
163
  )
93
- print("OK")
164
+ print("OK status updated")
94
165
 
95
166
 
96
167
  def cmd_inbox(db: BoardDB, identity: str) -> None:
168
+ validate_identity(db, identity)
97
169
  name = identity.lower()
98
- if not db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (name,)):
99
- print(f"ERROR: 会话 '{name}' 未注册")
100
- raise SystemExit(1)
101
170
  count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
102
171
  if not count:
103
- print("(empty)")
104
- return
172
+ print("收件箱为空")
173
+ else:
174
+ rows = db.query(
175
+ "SELECT i.message_id, m.ts, m.sender, m.body "
176
+ "FROM inbox i JOIN messages m ON i.message_id=m.id "
177
+ "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
178
+ (name,),
179
+ )
180
+ max_id = 0
181
+ for msg_id, msg_ts, sender, body in rows:
182
+ print(f'<message from="{sender}" ts="{msg_ts}">\n{body}\n</message>')
183
+ if msg_id > max_id:
184
+ max_id = msg_id
185
+ if max_id > 0:
186
+ _ack_marker_path(db, name).write_text(str(max_id))
187
+
188
+ _task_print_queue_short(db, name)
189
+
190
+
191
+ def _task_print_queue_short(db: BoardDB, target: str) -> None:
105
192
  rows = db.query(
106
- "SELECT i.message_id, m.ts, m.sender, m.body "
107
- "FROM inbox i JOIN messages m ON i.message_id=m.id "
108
- "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
109
- (name,),
193
+ "SELECT id, status, priority, description FROM tasks "
194
+ "WHERE session=? AND status != 'done' "
195
+ "ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, priority DESC, id ASC",
196
+ (target,),
110
197
  )
111
- max_id = 0
112
- for msg_id, msg_ts, sender, body in rows:
113
- print(f'<message from="{sender}" ts="{msg_ts}">\n{body}\n</message>')
114
- if msg_id > max_id:
115
- max_id = msg_id
116
- if max_id > 0:
117
- _ack_marker_path(db, name).write_text(str(max_id))
198
+ print("\n任务队列:")
199
+ if not rows:
200
+ print(" (无待办任务)")
201
+ return
202
+ for tid, status, priority, desc in rows:
203
+ marker = "*" if status == "active" else " "
204
+ print(f" {marker} #{tid} [{status} p{priority}] {desc}")
118
205
 
119
206
 
120
207
  def cmd_ack(db: BoardDB, identity: str) -> None:
208
+ validate_identity(db, identity)
121
209
  name = identity.lower()
122
210
  marker = _ack_marker_path(db, name)
123
211
  max_id = None
@@ -136,7 +224,7 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
136
224
  count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
137
225
 
138
226
  if count == 0:
139
- print("OK")
227
+ print("收件箱已经是空的")
140
228
  marker.unlink(missing_ok=True)
141
229
  return
142
230
 
@@ -148,7 +236,7 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
148
236
  else:
149
237
  db.execute("UPDATE inbox SET read=1 WHERE session=? AND read=0", (name,))
150
238
 
151
- print(f"OK {count}")
239
+ print(f"OK {count} 条已清空(完整记录在 messages.log)")
152
240
  marker.unlink(missing_ok=True)
153
241
 
154
242
 
@@ -0,0 +1,233 @@
1
+ """board_pending — pending actions queue: add / list / verify / retry / resolve."""
2
+
3
+ import subprocess
4
+
5
+ from lib.board_db import BoardDB, ts
6
+ from lib.common import parse_flags
7
+
8
+
9
+ VALID_TYPES = ("auth", "approve", "confirm")
10
+ PENDING_STATUSES = ("pending", "reminded")
11
+
12
+
13
+ def cmd_pending(db: BoardDB, identity: str, args: list[str]) -> None:
14
+ subcmd = args[0] if args else "list"
15
+ rest = args[1:] if len(args) > 1 else []
16
+
17
+ if subcmd == "add":
18
+ _pending_add(db, identity, rest)
19
+ elif subcmd == "list":
20
+ _pending_list(db, identity, rest)
21
+ elif subcmd == "verify":
22
+ _pending_verify(db, identity, rest)
23
+ elif subcmd == "retry":
24
+ _pending_retry(db, identity, rest)
25
+ elif subcmd == "resolve":
26
+ _pending_resolve(db, identity, rest)
27
+ else:
28
+ print("Usage: ./board --as <name> pending {add|list|verify|retry|resolve}")
29
+ raise SystemExit(1)
30
+
31
+
32
+ def _pending_add(db: BoardDB, identity: str, args: list[str]) -> None:
33
+ name = identity.lower()
34
+ flags, positional = parse_flags(
35
+ args,
36
+ value_flags={
37
+ "type": ["--type", "-t"],
38
+ "command": ["--command", "-c"],
39
+ "reason": ["--reason", "-r"],
40
+ "verify": ["--verify", "-v"],
41
+ "retry": ["--retry"],
42
+ },
43
+ )
44
+
45
+ action_type = str(flags.get("type", "")).lower()
46
+ command = str(flags.get("command", ""))
47
+ reason = str(flags.get("reason", ""))
48
+ verify_cmd = str(flags.get("verify", "")) or None
49
+ retry_cmd = str(flags.get("retry", "")) or None
50
+
51
+ if not action_type or not command or not reason:
52
+ print("Usage: ./board --as <name> pending add --type <auth|approve|confirm> --command <cmd> --reason <why> [--verify <cmd>] [--retry <cmd>]")
53
+ raise SystemExit(1)
54
+
55
+ if action_type not in VALID_TYPES:
56
+ print(f"ERROR: 类型必须是 {', '.join(VALID_TYPES)} 之一")
57
+ raise SystemExit(1)
58
+
59
+ now = ts()
60
+ action_id = db.execute(
61
+ "INSERT INTO pending_actions(type, command, reason, verify_command, retry_command, created_by, created_at) "
62
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
63
+ (action_type, command, reason, verify_cmd, retry_cmd, name, now),
64
+ )
65
+ print(f"OK pending #{action_id} added ({action_type})")
66
+ print(f" 用户需执行: {command}")
67
+ print(f" 原因: {reason}")
68
+
69
+
70
+ def _pending_list(db: BoardDB, identity: str, args: list[str]) -> None:
71
+ flags, _ = parse_flags(args, bool_flags={"all": ["--all", "-a"]})
72
+ show_all = bool(flags.get("all"))
73
+
74
+ if show_all:
75
+ rows = db.query(
76
+ "SELECT id, type, command, reason, verify_command, retry_command, status, created_by, created_at, resolved_at "
77
+ "FROM pending_actions ORDER BY id"
78
+ )
79
+ else:
80
+ rows = db.query(
81
+ "SELECT id, type, command, reason, verify_command, retry_command, status, created_by, created_at, resolved_at "
82
+ "FROM pending_actions WHERE status IN ('pending', 'reminded') ORDER BY id"
83
+ )
84
+
85
+ if not rows:
86
+ print("无待处理操作" if not show_all else "无操作记录")
87
+ return
88
+
89
+ print("=== 待处理操作 ===" if not show_all else "=== 所有操作 ===")
90
+ print()
91
+ for row in rows:
92
+ aid, atype, cmd, reason, verify, retry, status, creator, created, resolved = row
93
+ status_icon = {"pending": "⏳", "reminded": "🔔", "done": "✓", "retried": "✓✓", "failed": "✗"}.get(status, "?")
94
+ print(f" #{aid} [{status_icon} {status}] ({atype}) by {creator}")
95
+ print(f" 用户需执行: ! {cmd}")
96
+ print(f" 原因: {reason}")
97
+ if verify:
98
+ print(f" 验证命令: {verify}")
99
+ if retry:
100
+ print(f" 重试命令: {retry}")
101
+ if resolved:
102
+ print(f" 完成于: {resolved}")
103
+ print()
104
+
105
+
106
+ def _pending_verify(db: BoardDB, identity: str, args: list[str]) -> None:
107
+ specific_id = None
108
+ if args:
109
+ try:
110
+ specific_id = int(args[0].lstrip("#"))
111
+ except ValueError:
112
+ print("Usage: ./board --as <name> pending verify [#id]")
113
+ raise SystemExit(1)
114
+
115
+ if specific_id:
116
+ rows = db.query(
117
+ "SELECT id, verify_command, command FROM pending_actions WHERE id=? AND status IN ('pending', 'reminded')",
118
+ (specific_id,),
119
+ )
120
+ else:
121
+ rows = db.query(
122
+ "SELECT id, verify_command, command FROM pending_actions WHERE status IN ('pending', 'reminded') AND verify_command IS NOT NULL ORDER BY id"
123
+ )
124
+
125
+ if not rows:
126
+ print("无可验证的操作")
127
+ return
128
+
129
+ verified = 0
130
+ failed = 0
131
+ for aid, verify_cmd, cmd in rows:
132
+ if not verify_cmd:
133
+ continue
134
+ try:
135
+ r = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True, timeout=30)
136
+ if r.returncode == 0:
137
+ now = ts()
138
+ db.execute(
139
+ "UPDATE pending_actions SET status='done', resolved_at=? WHERE id=?",
140
+ (now, aid),
141
+ )
142
+ print(f" #{aid}: 验证通过 ✓")
143
+ verified += 1
144
+ else:
145
+ db.execute("UPDATE pending_actions SET status='reminded' WHERE id=?", (aid,))
146
+ print(f" #{aid}: 验证失败 — 用户仍需执行: ! {cmd}")
147
+ failed += 1
148
+ except subprocess.TimeoutExpired:
149
+ print(f" #{aid}: 验证超时")
150
+ failed += 1
151
+ except OSError as e:
152
+ print(f" #{aid}: 验证出错: {e}")
153
+ failed += 1
154
+
155
+ print(f"\n验证结果: {verified} 通过, {failed} 未通过")
156
+
157
+
158
+ def _pending_retry(db: BoardDB, identity: str, args: list[str]) -> None:
159
+ specific_id = None
160
+ if args:
161
+ try:
162
+ specific_id = int(args[0].lstrip("#"))
163
+ except ValueError:
164
+ print("Usage: ./board --as <name> pending retry [#id]")
165
+ raise SystemExit(1)
166
+
167
+ if specific_id:
168
+ rows = db.query(
169
+ "SELECT id, retry_command FROM pending_actions WHERE id=? AND status='done'",
170
+ (specific_id,),
171
+ )
172
+ else:
173
+ rows = db.query(
174
+ "SELECT id, retry_command FROM pending_actions WHERE status='done' AND retry_command IS NOT NULL ORDER BY id"
175
+ )
176
+
177
+ if not rows:
178
+ print("无可重试的操作(需先通过验证)")
179
+ return
180
+
181
+ retried = 0
182
+ failed = 0
183
+ for aid, retry_cmd in rows:
184
+ if not retry_cmd:
185
+ continue
186
+ try:
187
+ r = subprocess.run(retry_cmd, shell=True, capture_output=True, text=True, timeout=60)
188
+ if r.returncode == 0:
189
+ db.execute("UPDATE pending_actions SET status='retried' WHERE id=?", (aid,))
190
+ print(f" #{aid}: 重试成功 ✓")
191
+ retried += 1
192
+ else:
193
+ db.execute("UPDATE pending_actions SET status='failed' WHERE id=?", (aid,))
194
+ print(f" #{aid}: 重试失败 — {r.stderr.strip() or r.stdout.strip() or 'exit ' + str(r.returncode)}")
195
+ failed += 1
196
+ except subprocess.TimeoutExpired:
197
+ db.execute("UPDATE pending_actions SET status='failed' WHERE id=?", (aid,))
198
+ print(f" #{aid}: 重试超时")
199
+ failed += 1
200
+ except OSError as e:
201
+ db.execute("UPDATE pending_actions SET status='failed' WHERE id=?", (aid,))
202
+ print(f" #{aid}: 重试出错: {e}")
203
+ failed += 1
204
+
205
+ print(f"\n重试结果: {retried} 成功, {failed} 失败")
206
+
207
+
208
+ def _pending_resolve(db: BoardDB, identity: str, args: list[str]) -> None:
209
+ if not args:
210
+ print("Usage: ./board --as <name> pending resolve <#id>")
211
+ raise SystemExit(1)
212
+
213
+ try:
214
+ action_id = int(args[0].lstrip("#"))
215
+ except ValueError:
216
+ print("Usage: ./board --as <name> pending resolve <#id>")
217
+ raise SystemExit(1)
218
+
219
+ row = db.query_one("SELECT status FROM pending_actions WHERE id=?", (action_id,))
220
+ if not row:
221
+ print(f"ERROR: pending #{action_id} 不存在")
222
+ raise SystemExit(1)
223
+
224
+ if row[0] not in PENDING_STATUSES:
225
+ print(f"pending #{action_id} 已是 {row[0]} 状态")
226
+ return
227
+
228
+ now = ts()
229
+ db.execute(
230
+ "UPDATE pending_actions SET status='done', resolved_at=? WHERE id=?",
231
+ (now, action_id),
232
+ )
233
+ print(f"OK pending #{action_id} 已手动标记为完成")
@@ -0,0 +1,14 @@
1
+ """board_pulse — lightweight heartbeat + unread count for PostToolBatch hook."""
2
+
3
+ from lib.board_db import BoardDB, ts
4
+ from lib.common import validate_identity
5
+
6
+
7
+ def cmd_pulse(db: BoardDB, identity: str) -> None:
8
+ name = identity.lower()
9
+ validate_identity(db, identity)
10
+ now = ts()
11
+ db.execute("UPDATE sessions SET last_heartbeat=? WHERE name=?", (now, name))
12
+ count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
13
+ if count:
14
+ print(f"{count} 条未读")