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
@@ -1,6 +1,5 @@
1
1
  """board_maintenance — data maintenance: prune, backup, restore."""
2
2
 
3
- import shutil
4
3
  import time
5
4
  from pathlib import Path
6
5
 
@@ -14,9 +13,9 @@ from lib.board_db import BoardDB
14
13
  def cmd_prune(db: BoardDB, args: list[str]) -> None:
15
14
  """Prune old messages and inbox entries.
16
15
 
17
- Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]
16
+ Usage: board --as <name> prune [--before YYYY-MM-DD] [--dry-run]
18
17
  """
19
- usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]"
18
+ usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--dry-run]"
20
19
 
21
20
  before_days = 90 # default: keep 90 days
22
21
  dry_run = False
@@ -27,13 +26,6 @@ def cmd_prune(db: BoardDB, args: list[str]) -> None:
27
26
  if args[i] == "--before" and i + 1 < len(args):
28
27
  before_days = _parse_days(args[i + 1])
29
28
  i += 2
30
- elif args[i] == "--keep" and i + 1 < len(args):
31
- try:
32
- int(args[i + 1])
33
- except ValueError:
34
- print(f"ERROR: --keep requires a number, got '{args[i + 1]}'")
35
- raise SystemExit(1)
36
- i += 2
37
29
  elif args[i] == "--dry-run":
38
30
  dry_run = True
39
31
  i += 1
@@ -48,22 +40,29 @@ def cmd_prune(db: BoardDB, args: list[str]) -> None:
48
40
  cutoff = _days_ago_ts(before_days)
49
41
 
50
42
  # Count what would be deleted
51
- old_inbox = db.scalar(
52
- "SELECT COUNT(*) FROM inbox WHERE message_id IN "
53
- "(SELECT id FROM messages WHERE ts < ?)",
54
- (cutoff,),
55
- ) or 0
56
- old_messages = db.scalar(
57
- "SELECT COUNT(*) FROM messages WHERE ts < ?",
58
- (cutoff,),
59
- ) or 0
43
+ old_inbox = (
44
+ db.scalar(
45
+ "SELECT COUNT(*) FROM inbox WHERE message_id IN (SELECT id FROM messages WHERE ts < ?)",
46
+ (cutoff,),
47
+ )
48
+ or 0
49
+ )
50
+ old_messages = (
51
+ db.scalar(
52
+ "SELECT COUNT(*) FROM messages WHERE ts < ?",
53
+ (cutoff,),
54
+ )
55
+ or 0
56
+ )
60
57
 
61
58
  # Also count old read inbox entries (not just those linked to old messages)
62
- old_read_inbox = db.scalar(
63
- "SELECT COUNT(*) FROM inbox WHERE read=1 AND "
64
- "delivered_at < ?",
65
- (cutoff,),
66
- ) or 0
59
+ old_read_inbox = (
60
+ db.scalar(
61
+ "SELECT COUNT(*) FROM inbox WHERE read=1 AND delivered_at < ?",
62
+ (cutoff,),
63
+ )
64
+ or 0
65
+ )
67
66
 
68
67
  if dry_run:
69
68
  print("=== DRY RUN: would delete ===")
@@ -204,9 +203,7 @@ def cmd_restore(db: BoardDB, args: list[str]) -> None:
204
203
  try:
205
204
  conn = sqlite3.connect(str(source))
206
205
  conn.execute("PRAGMA integrity_check")
207
- tables = conn.execute(
208
- "SELECT name FROM sqlite_master WHERE type='table'"
209
- ).fetchall()
206
+ tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
210
207
  conn.close()
211
208
  if not tables:
212
209
  print("ERROR: backup file contains no tables")
@@ -229,6 +226,8 @@ def cmd_restore(db: BoardDB, args: list[str]) -> None:
229
226
  return
230
227
 
231
228
  # Atomic restore: copy to temp, rename
229
+ import shutil
230
+
232
231
  tmp = db.db_path.with_suffix(".db.restore-tmp")
233
232
  shutil.copy2(source, tmp)
234
233
  tmp.replace(db.db_path)
package/lib/board_msg.py CHANGED
@@ -1,25 +1,60 @@
1
1
  """board_msg — send / inbox / ack / status / log commands."""
2
2
 
3
3
  import hashlib
4
+ import re
4
5
  import shutil
5
6
  import subprocess
6
7
  from pathlib import Path
7
8
 
8
9
  from lib.board_db import BoardDB, ts
9
- from lib.common import parse_flags
10
+ from lib.common import parse_flags, validate_identity
10
11
 
11
12
 
12
13
  def _ack_marker_path(db: BoardDB, name: str) -> Path:
13
14
  """Path to file recording max message_id seen by last inbox call."""
15
+ assert db.env is not None
14
16
  return db.env.sessions_dir / f".{name}.ack_max_id"
15
17
 
16
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
+
17
50
  def _nudge_session(db: BoardDB, recipient: str) -> None:
18
51
  """Inject a fixed prompt into the recipient's tmux session to check inbox.
19
52
 
20
- Only sends a hardcoded command never user-controlled content to avoid
21
- shell metacharacter injection via tmux send-keys.
53
+ Only nudges idle agentsbusy 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.
22
56
  """
57
+ assert db.env is not None
23
58
  if recipient == "all":
24
59
  sessions = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all'")]
25
60
  else:
@@ -27,17 +62,25 @@ def _nudge_session(db: BoardDB, recipient: str) -> None:
27
62
  prefix = db.env.prefix
28
63
  board = db.env.install_home / "bin" / "board"
29
64
  for name in sessions:
30
- if not name.isalnum():
65
+ if not re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
31
66
  continue
32
67
  sess = f"{prefix}-{name}"
33
- r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
34
- if r.returncode == 0:
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
35
74
  cmd = f"{board} --as {name} inbox"
36
- subprocess.run(["tmux", "send-keys", "-t", sess, "-l", cmd], capture_output=True)
37
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], capture_output=True)
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
38
79
 
39
80
 
40
81
  def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
82
+ assert db.env is not None
83
+ validate_identity(db, identity)
41
84
  name = identity.lower()
42
85
  flags, send_args = parse_flags(args, value_flags={"attach": ["--attach", "-a"]})
43
86
  attach_file = flags.get("attach")
@@ -48,9 +91,8 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
48
91
 
49
92
  to = send_args[0].lower()
50
93
 
51
- if to != "all" and not db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (to,)):
52
- print(f"ERROR: 收件人 '{to}' 不存在")
53
- raise SystemExit(1)
94
+ if to != "all":
95
+ db.ensure_session(to)
54
96
 
55
97
  msg = " ".join(send_args[1:]) if len(send_args) > 1 else ""
56
98
 
@@ -62,7 +104,7 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
62
104
  stored_path = ""
63
105
  h = ""
64
106
  if attach_file:
65
- path = Path(attach_file)
107
+ path = Path(str(attach_file))
66
108
  if not path.is_file():
67
109
  print(f"ERROR: file not found: {attach_file}")
68
110
  raise SystemExit(1)
@@ -107,6 +149,7 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
107
149
 
108
150
 
109
151
  def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
152
+ validate_identity(db, identity)
110
153
  name = identity.lower()
111
154
  if not args:
112
155
  print("Usage: ./board --as <name> status <description>")
@@ -118,39 +161,51 @@ def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
118
161
  "UPDATE sessions SET status=?, updated_at=? WHERE name=?",
119
162
  (full_status, now, name),
120
163
  )
121
- db.sync_status_to_file(name, full_status)
122
164
  print("OK status updated")
123
165
 
124
166
 
125
167
  def cmd_inbox(db: BoardDB, identity: str) -> None:
168
+ validate_identity(db, identity)
126
169
  name = identity.lower()
127
- if not db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (name,)):
128
- print(f"ERROR: 会话 '{name}' 未注册")
129
- raise SystemExit(1)
130
170
  count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
131
171
  if not count:
132
172
  print("收件箱为空")
133
173
  else:
134
- print(f"你有 {count} 条未读:")
135
174
  rows = db.query(
136
- "SELECT i.message_id, '- [' || m.ts || '] **' || m.sender || '**: ' || m.body "
175
+ "SELECT i.message_id, m.ts, m.sender, m.body "
137
176
  "FROM inbox i JOIN messages m ON i.message_id=m.id "
138
177
  "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
139
178
  (name,),
140
179
  )
141
180
  max_id = 0
142
- for msg_id, line in rows:
143
- print(line)
181
+ for msg_id, msg_ts, sender, body in rows:
182
+ print(f'<message from="{sender}" ts="{msg_ts}">\n{body}\n</message>')
144
183
  if msg_id > max_id:
145
184
  max_id = msg_id
146
185
  if max_id > 0:
147
186
  _ack_marker_path(db, name).write_text(str(max_id))
148
- print(f"\n(运行 ack 清除: board --as {identity} ack)")
149
187
 
150
188
  _task_print_queue_short(db, name)
151
189
 
152
190
 
191
+ def _task_print_queue_short(db: BoardDB, target: str) -> None:
192
+ rows = db.query(
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,),
197
+ )
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}")
205
+
206
+
153
207
  def cmd_ack(db: BoardDB, identity: str) -> None:
208
+ validate_identity(db, identity)
154
209
  name = identity.lower()
155
210
  marker = _ack_marker_path(db, name)
156
211
  max_id = None
@@ -170,7 +225,6 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
170
225
 
171
226
  if count == 0:
172
227
  print("收件箱已经是空的")
173
- db.clear_inbox_file(name)
174
228
  marker.unlink(missing_ok=True)
175
229
  return
176
230
 
@@ -183,7 +237,6 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
183
237
  db.execute("UPDATE inbox SET read=1 WHERE session=? AND read=0", (name,))
184
238
 
185
239
  print(f"OK {count} 条已清空(完整记录在 messages.log)")
186
- db.clear_inbox_file(name)
187
240
  marker.unlink(missing_ok=True)
188
241
 
189
242
 
@@ -212,19 +265,3 @@ def cmd_log(db: BoardDB, identity: str, args: list[str]) -> None:
212
265
  )
213
266
  for (line,) in reversed(rows):
214
267
  print(line)
215
-
216
-
217
- def _task_print_queue_short(db: BoardDB, target: str) -> None:
218
- rows = db.query(
219
- "SELECT id, status, priority, description FROM tasks "
220
- "WHERE session=? AND status != 'done' "
221
- "ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, priority DESC, id ASC",
222
- (target,),
223
- )
224
- print("\n任务队列:")
225
- if not rows:
226
- print(" (无待办任务)")
227
- return
228
- for tid, status, priority, desc in rows:
229
- marker = "*" if status == "active" else " "
230
- print(f" {marker} #{tid} [{status} p{priority}] {desc}")
@@ -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} 条未读")
package/lib/board_task.py CHANGED
@@ -1,19 +1,21 @@
1
1
  """board_task — task queue: add / done / list / next."""
2
2
 
3
3
  from lib.board_db import BoardDB, ts
4
- from lib.common import is_privileged, is_terminal_task_status, parse_flags
4
+ from lib.common import is_privileged, is_terminal_task_status, parse_flags, validate_identity
5
5
 
6
6
 
7
7
  def _promote_next(db: BoardDB, target: str) -> None:
8
- active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,))
9
- if active:
10
- return
11
- next_id = db.scalar(
12
- "SELECT id FROM tasks WHERE session=? AND status='pending' ORDER BY priority DESC, id ASC LIMIT 1",
13
- (target,),
14
- )
15
- if next_id:
16
- db.execute("UPDATE tasks SET status='active' WHERE id=?", (next_id,))
8
+ with db.conn() as c:
9
+ active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
10
+ if active:
11
+ return
12
+ next_id = db.scalar(
13
+ "SELECT id FROM tasks WHERE session=? AND status='pending' ORDER BY priority DESC, id ASC LIMIT 1",
14
+ (target,),
15
+ c=c,
16
+ )
17
+ if next_id:
18
+ db.execute("UPDATE tasks SET status='active' WHERE id=?", (next_id,), c=c)
17
19
 
18
20
 
19
21
  def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
@@ -46,6 +48,7 @@ def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
46
48
 
47
49
 
48
50
  def cmd_task(db: BoardDB, identity: str, args: list[str]) -> None:
51
+ validate_identity(db, identity)
49
52
  subcmd = args[0] if args else "list"
50
53
  rest = args[1:] if len(args) > 1 else []
51
54
 
@@ -77,46 +80,52 @@ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
77
80
 
78
81
  desc = " ".join(desc_parts)
79
82
 
80
- db.ensure_session(target)
83
+ with db.conn() as c:
84
+ db.ensure_session(target, c=c)
81
85
 
82
- active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,))
83
- status = "active" if not active else "pending"
86
+ active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
87
+ status = "active" if not active else "pending"
84
88
 
85
- task_id = db.execute(
86
- "INSERT INTO tasks(session, description, status, priority) VALUES (?, ?, ?, ?)",
87
- (target, desc, status, priority),
88
- )
89
- print(f"OK task #{task_id} added to {target} ({status})")
90
-
91
- if target != name:
92
- now = ts()
93
- msg_id = db.execute(
94
- "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
95
- (now, name, target, f"[TASK #{task_id}] {desc}"),
89
+ task_id = db.execute(
90
+ "INSERT INTO tasks(session, description, status, priority) VALUES (?, ?, ?, ?)",
91
+ (target, desc, status, priority),
92
+ c=c,
96
93
  )
97
- db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id))
98
- db.sync_inbox_to_file(target)
99
- print(f"OK notified {target}")
94
+ print(f"OK task #{task_id} added to {target} ({status})")
95
+
96
+ if target != name:
97
+ now = ts()
98
+ msg_id = db.execute(
99
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
100
+ (now, name, target, f"[TASK #{task_id}] {desc}"),
101
+ c=c,
102
+ )
103
+ db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id), c=c)
104
+ print(f"OK notified {target}")
100
105
  _print_queue(db, target)
101
106
 
102
107
 
103
108
  def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
104
109
  name = identity.lower()
105
110
 
106
- task_id = args[0] if args else None
111
+ raw_id: str | int | None = args[0] if args else None
107
112
 
108
- if not task_id:
113
+ if not raw_id:
109
114
  _promote_next(db, name)
110
- task_id = db.scalar(
115
+ raw_id = db.scalar(
111
116
  "SELECT id FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
112
117
  (name,),
113
118
  )
114
- if not task_id:
119
+ if not raw_id:
115
120
  print(f"No active task for {name}.")
116
121
  _print_queue(db, name)
117
122
  return
118
123
 
119
- task_id = int(task_id)
124
+ try:
125
+ task_id = int(raw_id)
126
+ except (ValueError, TypeError):
127
+ print(f"ERROR: 无效的任务 ID: {raw_id}")
128
+ raise SystemExit(1)
120
129
  row = db.query_one("SELECT session, status, description FROM tasks WHERE id=?", (task_id,))
121
130
  if not row:
122
131
  print(f"ERROR: task #{task_id} not found")