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.
- package/Makefile +8 -2
- package/README.md +40 -56
- package/VERSION +1 -1
- package/bin/board +102 -34
- package/bin/cnb +59 -33
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +8 -8
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- 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 +18 -6
- package/lib/board_lock.py +5 -0
- package/lib/board_mailbox.py +6 -4
- package/lib/board_msg.py +112 -24
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +22 -10
- package/lib/board_tui.py +28 -20
- package/lib/board_view.py +60 -28
- 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 +4 -1
- package/lib/concerns/coral.py +1 -1
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +1 -1
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +58 -3
- 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/monitor.py +8 -4
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +43 -35
- package/lib/swarm_backend.py +63 -29
- 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/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.
|
|
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
|
@@ -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
|
|
112
|
-
|
|
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")
|
package/lib/board_mailbox.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""board_mailbox — encrypted async messaging between registered agents."""
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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"
|
|
28
|
-
|
|
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("
|
|
104
|
-
|
|
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
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
(
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
print(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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("
|
|
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} 条未读")
|