claude-nb 0.3.0

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/LICENSE +38 -0
  2. package/Makefile +60 -0
  3. package/README.md +63 -0
  4. package/VERSION +1 -0
  5. package/bin/_pip_entry.py +25 -0
  6. package/bin/board +287 -0
  7. package/bin/cnb +150 -0
  8. package/bin/cnb.js +33 -0
  9. package/bin/dispatcher +151 -0
  10. package/bin/dispatcher-watchdog +57 -0
  11. package/bin/doctor +328 -0
  12. package/bin/init +316 -0
  13. package/bin/registry +347 -0
  14. package/bin/swarm +896 -0
  15. package/lib/__init__.py +1 -0
  16. package/lib/board_admin.py +128 -0
  17. package/lib/board_bbs.py +99 -0
  18. package/lib/board_bug.py +161 -0
  19. package/lib/board_db.py +262 -0
  20. package/lib/board_lock.py +113 -0
  21. package/lib/board_mailbox.py +145 -0
  22. package/lib/board_maintenance.py +237 -0
  23. package/lib/board_msg.py +230 -0
  24. package/lib/board_task.py +200 -0
  25. package/lib/board_view.py +366 -0
  26. package/lib/board_vote.py +164 -0
  27. package/lib/build_lock.py +221 -0
  28. package/lib/cli.py +34 -0
  29. package/lib/common.py +285 -0
  30. package/lib/concerns/__init__.py +42 -0
  31. package/lib/concerns/adaptive_throttle.py +26 -0
  32. package/lib/concerns/base.py +25 -0
  33. package/lib/concerns/bug_sla_checker.py +32 -0
  34. package/lib/concerns/config.py +22 -0
  35. package/lib/concerns/coral_manager.py +61 -0
  36. package/lib/concerns/coral_poker.py +57 -0
  37. package/lib/concerns/file_watcher.py +127 -0
  38. package/lib/concerns/health_checker.py +72 -0
  39. package/lib/concerns/helpers.py +152 -0
  40. package/lib/concerns/idle_detector.py +56 -0
  41. package/lib/concerns/idle_killer.py +41 -0
  42. package/lib/concerns/idle_nudger.py +38 -0
  43. package/lib/concerns/inbox_nudger.py +34 -0
  44. package/lib/concerns/resource_monitor.py +47 -0
  45. package/lib/concerns/session_keepalive.py +23 -0
  46. package/lib/concerns/time_announcer.py +34 -0
  47. package/lib/crypto.py +92 -0
  48. package/lib/health.py +187 -0
  49. package/lib/inject.py +164 -0
  50. package/lib/migrate.py +109 -0
  51. package/lib/monitor.py +373 -0
  52. package/lib/panel.py +137 -0
  53. package/lib/resources.py +341 -0
  54. package/migrations/001_foreign_keys.sql +77 -0
  55. package/migrations/002_session_persona.sql +1 -0
  56. package/migrations/003_mailbox.sql +9 -0
  57. package/package.json +28 -0
  58. package/pyproject.toml +71 -0
  59. package/registry/0001-meridian.json +12 -0
  60. package/registry/0002-forge.json +12 -0
  61. package/registry/0003-lead.json +12 -0
  62. package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
  63. package/registry/GENESIS.json +9 -0
  64. package/registry/pubkeys.json +5 -0
  65. package/schema.sql +138 -0
@@ -0,0 +1,230 @@
1
+ """board_msg — send / inbox / ack / status / log commands."""
2
+
3
+ import hashlib
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from lib.board_db import BoardDB, ts
9
+ from lib.common import parse_flags
10
+
11
+
12
+ def _ack_marker_path(db: BoardDB, name: str) -> Path:
13
+ """Path to file recording max message_id seen by last inbox call."""
14
+ return db.env.sessions_dir / f".{name}.ack_max_id"
15
+
16
+
17
+ def _nudge_session(db: BoardDB, recipient: str) -> None:
18
+ """Inject a fixed prompt into the recipient's tmux session to check inbox.
19
+
20
+ Only sends a hardcoded command — never user-controlled content — to avoid
21
+ shell metacharacter injection via tmux send-keys.
22
+ """
23
+ if recipient == "all":
24
+ sessions = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all'")]
25
+ else:
26
+ sessions = [recipient]
27
+ prefix = db.env.prefix
28
+ board = db.env.install_home / "bin" / "board"
29
+ for name in sessions:
30
+ if not name.isalnum():
31
+ continue
32
+ sess = f"{prefix}-{name}"
33
+ r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
34
+ if r.returncode == 0:
35
+ 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)
38
+
39
+
40
+ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
41
+ name = identity.lower()
42
+ flags, send_args = parse_flags(args, value_flags={"attach": ["--attach", "-a"]})
43
+ attach_file = flags.get("attach")
44
+
45
+ if not send_args:
46
+ print("Usage: ./board --as <name> send <to> <message> [--attach <file>]")
47
+ raise SystemExit(1)
48
+
49
+ to = send_args[0].lower()
50
+
51
+ if to != "all" and not db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (to,)):
52
+ print(f"ERROR: 收件人 '{to}' 不存在")
53
+ raise SystemExit(1)
54
+
55
+ msg = " ".join(send_args[1:]) if len(send_args) > 1 else ""
56
+
57
+ if not msg and not attach_file:
58
+ print("ERROR: 消息不能为空")
59
+ raise SystemExit(1)
60
+
61
+ attach_ref = ""
62
+ stored_path = ""
63
+ h = ""
64
+ if attach_file:
65
+ path = Path(attach_file)
66
+ if not path.is_file():
67
+ print(f"ERROR: file not found: {attach_file}")
68
+ raise SystemExit(1)
69
+ data = path.read_bytes()
70
+ h = hashlib.sha256(data).hexdigest()[:12]
71
+ ext = path.suffix.lstrip(".")
72
+ orig = path.name
73
+ stored_path = f"files/{h}.{ext}" if ext else f"files/{h}"
74
+ files_dir = db.env.claudes_dir / "files"
75
+ files_dir.mkdir(exist_ok=True)
76
+ dest = db.env.claudes_dir / stored_path
77
+ if not dest.exists():
78
+ shutil.copy2(str(path), str(dest))
79
+ attach_ref = f" [附件: {orig} → {stored_path}]"
80
+ if not msg:
81
+ msg = f"分享文件: {orig}"
82
+
83
+ full_msg = msg + attach_ref
84
+ now = ts()
85
+ attach_val = h if attach_file else None
86
+
87
+ with db.conn() as c:
88
+ if attach_file:
89
+ db.execute(
90
+ "INSERT OR IGNORE INTO files(hash, original_name, extension, sender, stored_path) "
91
+ "VALUES (?, ?, ?, ?, ?)",
92
+ (h, orig, ext, name, stored_path),
93
+ c=c,
94
+ )
95
+ msg_id = db.execute(
96
+ "INSERT INTO messages(ts, sender, recipient, body, attachment) VALUES (?, ?, ?, ?, ?)",
97
+ (now, name, to, full_msg, attach_val),
98
+ c=c,
99
+ )
100
+ db.deliver_to_inbox(name, to, msg_id, c=c)
101
+
102
+ print("OK sent")
103
+ if attach_ref:
104
+ print(f" 附件已存储: {stored_path}")
105
+
106
+ _nudge_session(db, to)
107
+
108
+
109
+ def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
110
+ name = identity.lower()
111
+ if not args:
112
+ print("Usage: ./board --as <name> status <description>")
113
+ raise SystemExit(1)
114
+ desc = " ".join(args)
115
+ now = ts()
116
+ full_status = f"{desc} — {now}"
117
+ db.execute(
118
+ "UPDATE sessions SET status=?, updated_at=? WHERE name=?",
119
+ (full_status, now, name),
120
+ )
121
+ db.sync_status_to_file(name, full_status)
122
+ print("OK status updated")
123
+
124
+
125
+ def cmd_inbox(db: BoardDB, identity: str) -> None:
126
+ 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
+ count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
131
+ if not count:
132
+ print("收件箱为空")
133
+ else:
134
+ print(f"你有 {count} 条未读:")
135
+ rows = db.query(
136
+ "SELECT i.message_id, '- [' || m.ts || '] **' || m.sender || '**: ' || m.body "
137
+ "FROM inbox i JOIN messages m ON i.message_id=m.id "
138
+ "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
139
+ (name,),
140
+ )
141
+ max_id = 0
142
+ for msg_id, line in rows:
143
+ print(line)
144
+ if msg_id > max_id:
145
+ max_id = msg_id
146
+ if max_id > 0:
147
+ _ack_marker_path(db, name).write_text(str(max_id))
148
+ print(f"\n(运行 ack 清除: board --as {identity} ack)")
149
+
150
+ _task_print_queue_short(db, name)
151
+
152
+
153
+ def cmd_ack(db: BoardDB, identity: str) -> None:
154
+ name = identity.lower()
155
+ marker = _ack_marker_path(db, name)
156
+ max_id = None
157
+ if marker.exists():
158
+ try:
159
+ max_id = int(marker.read_text().strip())
160
+ except ValueError:
161
+ pass
162
+
163
+ if max_id:
164
+ count = db.scalar(
165
+ "SELECT COUNT(*) FROM inbox WHERE session=? AND read=0 AND message_id<=?",
166
+ (name, max_id),
167
+ )
168
+ else:
169
+ count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
170
+
171
+ if count == 0:
172
+ print("收件箱已经是空的")
173
+ db.clear_inbox_file(name)
174
+ marker.unlink(missing_ok=True)
175
+ return
176
+
177
+ if max_id:
178
+ db.execute(
179
+ "UPDATE inbox SET read=1 WHERE session=? AND read=0 AND message_id<=?",
180
+ (name, max_id),
181
+ )
182
+ else:
183
+ db.execute("UPDATE inbox SET read=1 WHERE session=? AND read=0", (name,))
184
+
185
+ print(f"OK {count} 条已清空(完整记录在 messages.log)")
186
+ db.clear_inbox_file(name)
187
+ marker.unlink(missing_ok=True)
188
+
189
+
190
+ def cmd_log(db: BoardDB, identity: str, args: list[str]) -> None:
191
+ flags, positional = parse_flags(args, bool_flags={"mine": ["--mine"]})
192
+ filter_name = identity.lower() if flags.get("mine") and identity else ""
193
+ n = 20
194
+ for a in positional:
195
+ try:
196
+ n = int(a)
197
+ except ValueError:
198
+ pass
199
+
200
+ if filter_name:
201
+ rows = db.query(
202
+ "SELECT '[' || ts || '] ' || sender || ' → ' || recipient || ': ' || body "
203
+ "FROM messages WHERE sender=? OR recipient=? OR recipient='all' "
204
+ "ORDER BY id DESC LIMIT ?",
205
+ (filter_name, filter_name, n),
206
+ )
207
+ else:
208
+ rows = db.query(
209
+ "SELECT '[' || ts || '] ' || sender || ' → ' || recipient || ': ' || body "
210
+ "FROM messages ORDER BY id DESC LIMIT ?",
211
+ (n,),
212
+ )
213
+ for (line,) in reversed(rows):
214
+ 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,200 @@
1
+ """board_task — task queue: add / done / list / next."""
2
+
3
+ from lib.board_db import BoardDB, ts
4
+ from lib.common import is_privileged, is_terminal_task_status, parse_flags
5
+
6
+
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,))
17
+
18
+
19
+ def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
20
+ if include_done:
21
+ rows = db.query(
22
+ "SELECT id, status, priority, description, created_at, COALESCE(done_at, '') "
23
+ "FROM tasks WHERE session=? "
24
+ "ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, "
25
+ "priority DESC, id ASC",
26
+ (target,),
27
+ )
28
+ else:
29
+ rows = db.query(
30
+ "SELECT id, status, priority, description, created_at, '' "
31
+ "FROM tasks WHERE session=? AND status NOT IN ('done') "
32
+ "ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, "
33
+ "priority DESC, id ASC",
34
+ (target,),
35
+ )
36
+ print("\n任务队列:")
37
+ if not rows:
38
+ print(" (无待办任务)")
39
+ return
40
+ for tid, status, priority, desc, _created, done_at in rows:
41
+ marker = "*" if status == "active" else " "
42
+ if status == "done":
43
+ print(f" {marker} #{tid} [{status} p{priority}] {desc} (done {done_at})")
44
+ else:
45
+ print(f" {marker} #{tid} [{status} p{priority}] {desc}")
46
+
47
+
48
+ def cmd_task(db: BoardDB, identity: str, args: list[str]) -> None:
49
+ subcmd = args[0] if args else "list"
50
+ rest = args[1:] if len(args) > 1 else []
51
+
52
+ if subcmd == "add":
53
+ _task_add(db, identity, rest)
54
+ elif subcmd == "done":
55
+ _task_done(db, identity, rest)
56
+ elif subcmd == "list":
57
+ _task_list(db, identity, rest)
58
+ elif subcmd == "next":
59
+ _task_next(db, identity)
60
+ else:
61
+ print("Usage: ./board --as <name> task {add|done|list|next}")
62
+ raise SystemExit(1)
63
+
64
+
65
+ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
66
+ name = identity.lower()
67
+ flags, desc_parts = parse_flags(
68
+ args,
69
+ value_flags={"to": ["--to"], "priority": ["--priority", "-p"]},
70
+ )
71
+ target = str(flags.get("to", name)).lower()
72
+ priority = int(flags["priority"]) if "priority" in flags else 0
73
+
74
+ if not desc_parts:
75
+ print("Usage: ./board --as <name> task add [--to session] [--priority N] <description>")
76
+ raise SystemExit(1)
77
+
78
+ desc = " ".join(desc_parts)
79
+
80
+ db.ensure_session(target)
81
+
82
+ active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,))
83
+ status = "active" if not active else "pending"
84
+
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}"),
96
+ )
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}")
100
+ _print_queue(db, target)
101
+
102
+
103
+ def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
104
+ name = identity.lower()
105
+
106
+ task_id = args[0] if args else None
107
+
108
+ if not task_id:
109
+ _promote_next(db, name)
110
+ task_id = db.scalar(
111
+ "SELECT id FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
112
+ (name,),
113
+ )
114
+ if not task_id:
115
+ print(f"No active task for {name}.")
116
+ _print_queue(db, name)
117
+ return
118
+
119
+ task_id = int(task_id)
120
+ row = db.query_one("SELECT session, status, description FROM tasks WHERE id=?", (task_id,))
121
+ if not row:
122
+ print(f"ERROR: task #{task_id} not found")
123
+ raise SystemExit(1)
124
+
125
+ assignee, status, desc = row
126
+ if assignee != name and not is_privileged(name):
127
+ print(f"ERROR: task #{task_id} belongs to {assignee}; only owner, Orca, or Coral can mark it done")
128
+ raise SystemExit(1)
129
+
130
+ if is_terminal_task_status(status):
131
+ print(f"Task #{task_id} is already done.")
132
+ _print_queue(db, assignee)
133
+ return
134
+
135
+ now = ts()
136
+ db.execute("UPDATE tasks SET status='done', done_at=? WHERE id=?", (now, task_id))
137
+ print(f"OK task #{task_id} done: {desc}")
138
+
139
+ _promote_next(db, assignee)
140
+ nxt = db.query_one(
141
+ "SELECT id, description FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
142
+ (assignee,),
143
+ )
144
+ if nxt:
145
+ print(f"Next: #{nxt[0]} {nxt[1]}")
146
+ else:
147
+ print(f"No remaining active/pending tasks for {assignee}.")
148
+ _print_queue(db, assignee)
149
+
150
+
151
+ def _task_list(db: BoardDB, identity: str, args: list[str]) -> None:
152
+ flags, positional = parse_flags(
153
+ args,
154
+ value_flags={"session": ["--session"]},
155
+ bool_flags={"all": ["--all"], "done": ["--done", "--include-done"]},
156
+ )
157
+ all_sessions = bool(flags.get("all"))
158
+ include_done = bool(flags.get("done"))
159
+ target = str(flags["session"]).lower() if "session" in flags else ""
160
+
161
+ if not target and positional:
162
+ target = positional[0].lower()
163
+ if len(positional) > 1:
164
+ print("Usage: ./board task list [session|--all] [--done]")
165
+ raise SystemExit(1)
166
+
167
+ if all_sessions:
168
+ print("=== Task Queue ===")
169
+ if include_done:
170
+ rows = db.query(
171
+ "SELECT session, id, status, priority, description FROM tasks "
172
+ "ORDER BY session, CASE status WHEN 'active' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, "
173
+ "priority DESC, id ASC"
174
+ )
175
+ else:
176
+ rows = db.query(
177
+ "SELECT session, id, status, priority, description FROM tasks WHERE status != 'done' "
178
+ "ORDER BY session, CASE status WHEN 'active' THEN 0 ELSE 1 END, "
179
+ "priority DESC, id ASC"
180
+ )
181
+ if not rows:
182
+ print(" (no tasks)")
183
+ return
184
+ for session, tid, status, priority, desc in rows:
185
+ print(f" {session:<8s} #{tid} [{status} p{priority}] {desc}")
186
+ return
187
+
188
+ if not target:
189
+ if not identity:
190
+ print("Usage: ./board task list [session|--all] [--done]")
191
+ raise SystemExit(1)
192
+ target = identity.lower()
193
+ _print_queue(db, target, include_done)
194
+
195
+
196
+ def _task_next(db: BoardDB, identity: str) -> None:
197
+ name = identity.lower()
198
+
199
+ _promote_next(db, name)
200
+ _print_queue(db, name)