@misterhuydo/sentinel 1.0.52 → 1.0.53

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/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-03-22T10:41:20.633Z
1
+ 2026-03-22T11:21:17.183Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-22T10:44:11.780Z",
3
- "checkpoint_at": "2026-03-22T10:44:11.781Z",
2
+ "message": "Auto-checkpoint at 2026-03-22T11:32:35.191Z",
3
+ "checkpoint_at": "2026-03-22T11:32:35.192Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.52",
3
+ "version": "1.0.53",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -60,8 +60,7 @@ class SentinelConfig:
60
60
  slack_bot_token: str = "" # xoxb-...
61
61
  slack_app_token: str = "" # xapp-... (Socket Mode)
62
62
  slack_channel: str = "" # optional: restrict to one channel ID or name
63
- slack_watch_channels: list[str] = field(default_factory=list) # channels to passively monitor for bot errors
64
- slack_watch_bot_ids: list[str] = field(default_factory=list) # optional: only watch these bot IDs
63
+ slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
65
64
  project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
66
65
 
67
66
 
@@ -155,7 +154,6 @@ class ConfigLoader:
155
154
  c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
156
155
  c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
157
156
  c.slack_channel = d.get("SLACK_CHANNEL", "")
158
- c.slack_watch_channels = _csv(d.get("SLACK_WATCH_CHANNELS", ""))
159
157
  c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
160
158
  c.project_name = d.get("PROJECT_NAME", "")
161
159
  self.sentinel = c
@@ -89,8 +89,16 @@ def scan_issues(project_dir: Path) -> list[IssueEvent]:
89
89
  issues_dir = project_dir / "issues"
90
90
  issues_dir.mkdir(exist_ok=True)
91
91
 
92
+ def _priority(p: Path) -> tuple:
93
+ # slack-* (human) first, bot-* (bot) last, everything else in between
94
+ if p.name.startswith("slack-"):
95
+ return (0, p.name)
96
+ if p.name.startswith("bot-"):
97
+ return (2, p.name)
98
+ return (1, p.name)
99
+
92
100
  events = []
93
- for f in sorted(issues_dir.iterdir()):
101
+ for f in sorted(issues_dir.iterdir(), key=_priority):
94
102
  if not f.is_file() or f.name.startswith("."):
95
103
  continue
96
104
  if f.suffix.lower() in _BINARY_EXTENSIONS:
@@ -83,6 +83,18 @@ What you can do (tools available):
83
83
  e.g. "fetch logs", "try fetch_log.sh for SSOLWA", "fetch logs with debug",
84
84
  "grab latest logs from STS", "fetch logs without filter"
85
85
 
86
+ 15. watch_bot — Register a Slack bot for passive monitoring. Every message it posts is
87
+ auto-queued as an issue in the bot's registered project.
88
+ ALWAYS requires a project — infer from context or ask the user first.
89
+ e.g. "listen to @alertbot", "watch @bot1 @bot2 for project 1881", "monitor @errorbot"
90
+
91
+ 16. unwatch_bot — Remove a Slack bot from the passive watch list.
92
+ e.g. "stop watching @alertbot", "unwatch @errorbot"
93
+
94
+ 17. list_watched_bots — Show all Slack bots currently being passively monitored and which projects
95
+ they are delivering to.
96
+ e.g. "which bots are you watching?", "list monitored bots"
97
+
86
98
  When someone asks what you can do, what you support, what your capabilities are, or how you can help,
87
99
  reply with a short summary grouped by category:
88
100
 
@@ -109,6 +121,11 @@ reply with a short summary grouped by category:
109
121
  • `pull_repo` — git pull on managed application repos — "pull latest code"
110
122
  • `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
111
123
 
124
+ *Slack bot watching*
125
+ • `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues — "listen to @alertbot"
126
+ • `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
127
+ • `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
128
+
112
129
  Tone: direct, professional, like a senior engineer who owns the system.
113
130
  Don't pad responses. Don't say "Great question!" or "Certainly!".
114
131
  If you don't know something, use a tool to find out before saying you don't know.
@@ -338,6 +355,60 @@ _TOOLS = [
338
355
  },
339
356
  },
340
357
  },
358
+ {
359
+ "name": "watch_bot",
360
+ "description": (
361
+ "Tell Sentinel to passively monitor a Slack bot — queuing its messages as issues. "
362
+ "Extract all <@UXXXXXX> user IDs from the message and pass them here. "
363
+ "Sentinel verifies each is actually a bot (not a human) before adding to the watch list. "
364
+ "IMPORTANT: a bot watcher is only useful if its issues can be delivered to a project. "
365
+ "Try to infer the project from context (bot name, prior messages, available projects). "
366
+ "If it cannot be determined, do NOT call this tool — instead ask the user which project "
367
+ "the bot's alerts belong to, then call this tool with the project filled in. "
368
+ "Use for: 'listen to @alertbot', 'watch @bot1 @bot2', 'monitor @errorbot'."
369
+ ),
370
+ "input_schema": {
371
+ "type": "object",
372
+ "properties": {
373
+ "user_ids": {
374
+ "type": "array",
375
+ "items": {"type": "string"},
376
+ "description": "Slack user IDs to watch — extract from <@UXXXXXX> patterns in the message",
377
+ },
378
+ "project": {
379
+ "type": "string",
380
+ "description": "Project short name this bot's issues should be routed to (e.g. '1881', 'elprint'). Infer from context or ask user before calling.",
381
+ },
382
+ },
383
+ "required": ["user_ids"],
384
+ },
385
+ },
386
+ {
387
+ "name": "unwatch_bot",
388
+ "description": (
389
+ "Stop Sentinel from monitoring a Slack bot. "
390
+ "Use for: 'stop watching @alertbot', 'unwatch @bot', 'remove @errorbot from watchers'."
391
+ ),
392
+ "input_schema": {
393
+ "type": "object",
394
+ "properties": {
395
+ "user_ids": {
396
+ "type": "array",
397
+ "items": {"type": "string"},
398
+ "description": "Slack user IDs to remove from the watch list",
399
+ },
400
+ },
401
+ "required": ["user_ids"],
402
+ },
403
+ },
404
+ {
405
+ "name": "list_watched_bots",
406
+ "description": (
407
+ "List all Slack bots Sentinel is currently monitoring passively. "
408
+ "Use for: 'who are you watching?', 'which bots are you monitoring?', 'list watched bots'."
409
+ ),
410
+ "input_schema": {"type": "object", "properties": {}},
411
+ },
341
412
  ]
342
413
 
343
414
 
@@ -404,7 +475,7 @@ def _git_pull(path: Path) -> dict:
404
475
 
405
476
  # ── Tool execution ────────────────────────────────────────────────────────────
406
477
 
407
- def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
478
+ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None) -> str:
408
479
  if name == "get_status":
409
480
  hours = int(inputs.get("hours", 24))
410
481
  errors = store.get_recent_errors(hours)
@@ -700,6 +771,89 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
700
771
 
701
772
  return json.dumps({"fetched": len(results), "results": results})
702
773
 
774
+ if name == "watch_bot":
775
+ user_ids = inputs.get("user_ids", [])
776
+ project_arg = inputs.get("project", "").strip()
777
+ if not user_ids:
778
+ return json.dumps({"error": "No user_ids provided"})
779
+
780
+ # Resolve + validate project — required for bot issue routing
781
+ resolved_project = ""
782
+ if project_arg:
783
+ project_dirs = _find_project_dirs(project_arg)
784
+ if not project_dirs:
785
+ all_names = [_read_project_name(d) for d in _find_project_dirs()]
786
+ return json.dumps({
787
+ "error": f"No project found matching '{project_arg}'",
788
+ "available_projects": all_names,
789
+ "action_needed": "Ask the user which project these bot alerts belong to.",
790
+ })
791
+ if len(project_dirs) > 1:
792
+ matches = [_read_project_name(d) for d in project_dirs]
793
+ return json.dumps({
794
+ "error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
795
+ "action_needed": "Ask the user to clarify which project.",
796
+ })
797
+ resolved_project = _read_project_name(project_dirs[0])
798
+ else:
799
+ all_projects = _find_project_dirs()
800
+ if len(all_projects) == 1:
801
+ # Single project in workspace — auto-assign
802
+ resolved_project = _read_project_name(all_projects[0])
803
+ elif all_projects:
804
+ all_names = [_read_project_name(d) for d in all_projects]
805
+ return json.dumps({
806
+ "error": "Cannot determine which project these bot alerts belong to.",
807
+ "available_projects": all_names,
808
+ "action_needed": "Ask the user to specify the project, then retry with project filled in.",
809
+ })
810
+
811
+ results = []
812
+ for uid in user_ids:
813
+ if not slack_client:
814
+ results.append({"user_id": uid, "status": "error", "reason": "no Slack client available"})
815
+ continue
816
+ try:
817
+ info = await slack_client.users_info(user=uid)
818
+ user = info.get("user", {})
819
+ if not user.get("is_bot", False):
820
+ results.append({"user_id": uid, "status": "skipped", "reason": "not a bot — only bots can be watched passively"})
821
+ continue
822
+ bot_name = user.get("real_name") or user.get("name") or uid
823
+ store.add_watched_bot(uid, bot_name, added_by="boss", project_name=resolved_project)
824
+ logger.info("Boss: now watching bot %s (%s) → project '%s'", bot_name, uid, resolved_project or "unset")
825
+ results.append({"user_id": uid, "bot_name": bot_name, "project": resolved_project, "status": "watching"})
826
+ except Exception as e:
827
+ results.append({"user_id": uid, "status": "error", "reason": str(e)})
828
+ return json.dumps({"results": results})
829
+
830
+ if name == "unwatch_bot":
831
+ user_ids = inputs.get("user_ids", [])
832
+ if not user_ids:
833
+ return json.dumps({"error": "No user_ids provided"})
834
+ results = []
835
+ for uid in user_ids:
836
+ removed = store.remove_watched_bot(uid)
837
+ logger.info("Boss: unwatch bot %s → %s", uid, "removed" if removed else "not found")
838
+ results.append({"user_id": uid, "status": "removed" if removed else "not found"})
839
+ return json.dumps({"results": results})
840
+
841
+ if name == "list_watched_bots":
842
+ bots = store.get_watched_bots()
843
+ return json.dumps({
844
+ "count": len(bots),
845
+ "bots": [
846
+ {
847
+ "bot_id": b["bot_id"],
848
+ "bot_name": b["bot_name"],
849
+ "project": b.get("project_name") or "",
850
+ "added_by": b["added_by"],
851
+ "added_at": b["added_at"],
852
+ }
853
+ for b in bots
854
+ ],
855
+ })
856
+
703
857
  return json.dumps({"error": f"unknown tool: {name}"})
704
858
 
705
859
 
@@ -713,10 +867,11 @@ async def _handle_with_cli(
713
867
  history: list,
714
868
  cfg_loader,
715
869
  store,
870
+ slack_client=None,
716
871
  ) -> tuple[str, bool]:
717
872
  """Fallback: use `claude --print` for users without an Anthropic API key."""
718
- status_json = _run_tool("get_status", {"hours": 24}, cfg_loader, store)
719
- prs_json = _run_tool("list_pending_prs", {}, cfg_loader, store)
873
+ status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
874
+ prs_json = await _run_tool("list_pending_prs", {}, cfg_loader, store)
720
875
 
721
876
  # Pre-fetch log search if the message is a search request.
722
877
  # Use quoted strings as the query, or fall back to the full message.
@@ -726,7 +881,7 @@ async def _handle_with_cli(
726
881
  if any(kw in message.lower() for kw in _search_kws):
727
882
  quoted = re.findall(r'"([^"]+)"', message)
728
883
  query = quoted[0] if quoted else message
729
- search_json = _run_tool("search_logs", {"query": query}, cfg_loader, store)
884
+ search_json = await _run_tool("search_logs", {"query": query}, cfg_loader, store)
730
885
 
731
886
  paused = Path("SENTINEL_PAUSE").exists()
732
887
  repos = list(cfg_loader.repos.keys())
@@ -784,7 +939,7 @@ async def _handle_with_cli(
784
939
  action = json.loads(m.group(1))
785
940
  name = action.pop("action", "")
786
941
  if name:
787
- result_str = _run_tool(name, action, cfg_loader, store)
942
+ result_str = await _run_tool(name, action, cfg_loader, store)
788
943
  logger.info("Boss CLI action: %s → %s", name, result_str[:80])
789
944
  except Exception as e:
790
945
  logger.warning("Boss action parse error: %s", e)
@@ -805,6 +960,7 @@ async def handle_message(
805
960
  history: list,
806
961
  cfg_loader,
807
962
  store,
963
+ slack_client=None,
808
964
  ) -> tuple[str, bool]:
809
965
  """
810
966
  Process one user message through the Sentinel Boss (Claude with tool use).
@@ -830,7 +986,7 @@ async def handle_message(
830
986
 
831
987
  api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
832
988
  if not api_key:
833
- return await _handle_with_cli(message, history, cfg_loader, store)
989
+ return await _handle_with_cli(message, history, cfg_loader, store, slack_client=slack_client)
834
990
 
835
991
  client = anthropic.Anthropic(api_key=api_key)
836
992
 
@@ -880,7 +1036,7 @@ async def handle_message(
880
1036
  messages.append({"role": "assistant", "content": response.content})
881
1037
  tool_results = []
882
1038
  for tc in tool_blocks:
883
- result = _run_tool(tc.name, tc.input, cfg_loader, store)
1039
+ result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client)
884
1040
  logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
885
1041
  tool_results.append({
886
1042
  "type": "tool_result",
@@ -164,38 +164,11 @@ async def run_slack_bot(cfg_loader, store) -> None:
164
164
  if _allowed(event.get("channel", "")):
165
165
  await _dispatch(event, client, cfg_loader, store)
166
166
 
167
- # ── Watch-channel resolver ────────────────────────────────────────────────
168
- # Lazily resolves SLACK_WATCH_CHANNELS names → IDs on first message event
169
- _watch_ids: set[str] = set()
170
- _watch_resolved = [False] # mutable cell (closure-friendly)
171
-
172
- async def _resolve_watch_channels(client):
173
- if _watch_resolved[0]:
174
- return
175
- _watch_resolved[0] = True
176
- if not cfg.slack_watch_channels:
177
- return
178
- try:
179
- resp = await client.conversations_list(
180
- types="public_channel,private_channel", limit=1000
181
- )
182
- name_to_id = {ch["name"]: ch["id"] for ch in (resp.get("channels") or [])}
183
- except Exception as e:
184
- logger.warning("Could not list channels for watch resolution: %s", e)
185
- name_to_id = {}
186
- for raw in cfg.slack_watch_channels:
187
- raw = raw.lstrip("#")
188
- if raw.upper().startswith("C") and len(raw) >= 9:
189
- _watch_ids.add(raw.upper())
190
- logger.info("Sentinel watching channel (ID): %s", raw.upper())
191
- elif raw in name_to_id:
192
- _watch_ids.add(name_to_id[raw])
193
- logger.info("Sentinel watching channel: #%s → %s", raw, name_to_id[raw])
194
- else:
195
- logger.warning("Watch channel '%s' not found — skipping", raw)
196
-
197
- def _is_watch_channel(channel: str) -> bool:
198
- return bool(_watch_ids) and channel in _watch_ids
167
+ # ── Passive bot watcher — seed DB from config on startup ─────────────────
168
+ for bot_id_cfg in cfg.slack_watch_bot_ids:
169
+ if bot_id_cfg and not store.is_watched_bot(bot_id_cfg):
170
+ store.add_watched_bot(bot_id_cfg, bot_id_cfg, added_by="config")
171
+ logger.info("Seeded watched bot from config: %s", bot_id_cfg)
199
172
 
200
173
  @app.event("message")
201
174
  async def on_message(event, client):
@@ -204,76 +177,80 @@ async def run_slack_bot(cfg_loader, store) -> None:
204
177
  await _dispatch(event, client, cfg_loader, store)
205
178
  return
206
179
 
207
- # Passive bot watcher — bot messages in watched channels
208
- if event.get("bot_id") and event.get("channel"):
209
- if not _watch_resolved[0]:
210
- await _resolve_watch_channels(client)
211
- if _is_watch_channel(event["channel"]):
212
- await _handle_bot_message(event, client, cfg_loader, store)
180
+ # Passive bot watcher — bot messages from DB-registered bots
181
+ bot_id = event.get("bot_id", "")
182
+ if bot_id and event.get("channel") and store.is_watched_bot(bot_id):
183
+ await _handle_bot_message(event, client, cfg_loader, store)
213
184
 
214
185
  # ── Start ─────────────────────────────────────────────────────────────────
215
186
 
216
187
  handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
217
188
  logger.info("Sentinel Boss connected to Slack (Socket Mode)")
218
- if cfg.slack_watch_channels:
219
- logger.info("Sentinel will passively watch for bot errors in: %s", cfg.slack_watch_channels)
189
+ watched = store.get_watched_bots()
190
+ if watched:
191
+ logger.info("Sentinel passively watching %d bot(s): %s",
192
+ len(watched), [b["bot_name"] for b in watched])
220
193
  await handler.start_async()
221
194
 
222
195
 
223
196
  # ── Bot-message watcher ───────────────────────────────────────────────────────
224
197
 
225
- import re as _re
226
198
  import uuid as _uuid
227
199
  from pathlib import Path as _Path
228
200
 
229
- # Patterns that indicate an error report worth queuing
230
- _ERROR_RE = _re.compile(
231
- r"(?:exception|error|failed|failure|fatal|traceback|stacktrace"
232
- r"|null\s*pointer|out\s*of\s*memory|stack\s*overflow"
233
- r"|5\d\d\b.*(?:error|failed)" # HTTP 5xx
234
- r"|WARN|ERROR|FATAL|CRITICAL)",
235
- _re.IGNORECASE,
236
- )
237
-
238
-
239
- def _looks_like_error(text: str) -> bool:
240
- """Return True if the bot message appears to be an error report."""
241
- return bool(_ERROR_RE.search(text))
242
-
243
201
 
244
202
  async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
245
203
  """
246
- Called when a bot posts in a watched channel.
247
- If the message looks like an error, auto-queue it as a Sentinel issue.
204
+ Called when a watched bot posts a message.
205
+ Queues the message as an issue in the bot's registered project directory.
206
+ All messages from watched bots are queued — no error pre-filtering.
248
207
  """
249
- cfg = cfg_loader.sentinel
250
208
  bot_id = event.get("bot_id", "")
251
209
  channel = event.get("channel", "")
252
210
  ts = event.get("ts", "")
253
211
 
254
- # Optional bot-ID filter: skip bots not on the allow-list
255
- if cfg.slack_watch_bot_ids and bot_id not in cfg.slack_watch_bot_ids:
212
+ # Avoid duplicate issues
213
+ dedup_key = f"slack-watch:{channel}:{ts}"
214
+ if store.is_seen_dedup(dedup_key):
256
215
  return
216
+ store.mark_seen_dedup(dedup_key)
257
217
 
258
218
  # Flatten text (handle block-kit attachments too)
259
219
  text = event.get("text", "")
260
220
  for att in event.get("attachments", []):
261
221
  text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
262
222
  text = text.strip()
263
-
264
- if not text or not _looks_like_error(text):
223
+ if not text:
265
224
  return
266
225
 
267
- # Avoid duplicate issues — skip if we've seen this exact ts from this bot
268
- dedup_key = f"slack-watch:{channel}:{ts}"
269
- if store.is_seen_dedup(dedup_key):
270
- return
271
- store.mark_seen_dedup(dedup_key)
226
+ # Find the project this bot is registered to
227
+ bots = store.get_watched_bots()
228
+ bot_info = next((b for b in bots if b["bot_id"] == bot_id), None)
229
+ project_name = (bot_info or {}).get("project_name") or ""
230
+
231
+ # Resolve the project issues directory
232
+ workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
233
+ if project_name:
234
+ # workspace_dir is <sentinel_root>/workspace, project dirs are siblings
235
+ project_dirs = [
236
+ d for d in workspace.iterdir()
237
+ if d.is_dir() and d.name != "workspace"
238
+ and (d / "config" / "sentinel.properties").exists()
239
+ ]
240
+ matched = next(
241
+ (d for d in project_dirs
242
+ if d.name == project_name or
243
+ _read_project_name_from_dir(d) == project_name),
244
+ None,
245
+ )
246
+ issues_dir = (matched / "issues") if matched else (_Path(".") / "issues")
247
+ else:
248
+ issues_dir = _Path(".") / "issues"
249
+
250
+ issues_dir.mkdir(parents=True, exist_ok=True)
272
251
 
273
- # Write the issue file so Sentinel picks it up on the next poll
274
- issues_dir = _Path("issues")
275
- issues_dir.mkdir(exist_ok=True)
276
- fname = f"slack-watch-{_uuid.uuid4().hex[:8]}.txt"
252
+ uid = _uuid.uuid4().hex[:8]
253
+ fname = f"bot-{project_name or 'unknown'}-{uid}.txt"
277
254
  content = (
278
255
  f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
279
256
  f"SLACK_TS: {ts}\n\n"
@@ -281,7 +258,7 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
281
258
  )
282
259
  (issues_dir / fname).write_text(content, encoding="utf-8")
283
260
  _Path("SENTINEL_POLL_NOW").touch()
284
- logger.info("Bot watcher queued issue from %s: %s", bot_id, fname)
261
+ logger.info("Bot watcher queued issue from %s %s/%s", bot_id, issues_dir, fname)
285
262
 
286
263
  # React with 👀 so the team knows Sentinel noticed it
287
264
  if ts:
@@ -291,6 +268,21 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
291
268
  pass # reactions:write scope may not be granted — non-fatal
292
269
 
293
270
 
271
+ def _read_project_name_from_dir(project_dir: _Path) -> str:
272
+ """Read PROJECT_NAME from a project's sentinel.properties, or return dir name."""
273
+ props = project_dir / "config" / "sentinel.properties"
274
+ if not props.exists():
275
+ return project_dir.name
276
+ try:
277
+ for line in props.read_text(encoding="utf-8").splitlines():
278
+ line = line.strip()
279
+ if line.upper().startswith("PROJECT_NAME="):
280
+ return line.split("=", 1)[1].partition("#")[0].strip()
281
+ except OSError:
282
+ pass
283
+ return project_dir.name
284
+
285
+
294
286
  # ── Dispatcher ────────────────────────────────────────────────────────────────
295
287
 
296
288
  async def _dispatch(event: dict, client, cfg_loader, store) -> None:
@@ -328,7 +320,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
328
320
 
329
321
  try:
330
322
  reply, is_done = await handle_message(
331
- message, session.history, cfg_loader, store
323
+ message, session.history, cfg_loader, store,
324
+ slack_client=client,
332
325
  )
333
326
  except Exception as e:
334
327
  logger.exception("Sentinel Boss error: %s", e)
@@ -74,10 +74,11 @@ class StateStore:
74
74
  def _migrate(self):
75
75
  """Add new columns to existing DBs without breaking old installs."""
76
76
  migrations = [
77
- ("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
78
- ("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
79
- ("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
80
- ("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
77
+ ("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
78
+ ("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
79
+ ("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
80
+ ("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
81
+ ("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
81
82
  ]
82
83
  with self._conn() as conn:
83
84
  done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
@@ -299,3 +300,42 @@ class StateStore:
299
300
  "INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
300
301
  (key, _now()),
301
302
  )
303
+
304
+ # ── Watched bots (Slack passive monitor) ─────────────────────────────────
305
+
306
+ def _ensure_watched_bots_table(self, conn):
307
+ conn.execute(
308
+ "CREATE TABLE IF NOT EXISTS watched_bots "
309
+ "(bot_id TEXT PRIMARY KEY, bot_name TEXT, added_by TEXT, added_at TEXT)"
310
+ )
311
+
312
+ def add_watched_bot(self, bot_id: str, bot_name: str, added_by: str = "config", project_name: str = ""):
313
+ with self._conn() as conn:
314
+ self._ensure_watched_bots_table(conn)
315
+ conn.execute(
316
+ "INSERT OR REPLACE INTO watched_bots (bot_id, bot_name, added_by, added_at, project_name) "
317
+ "VALUES (?, ?, ?, ?, ?)",
318
+ (bot_id, bot_name, added_by, _now(), project_name or None),
319
+ )
320
+
321
+ def remove_watched_bot(self, bot_id: str) -> bool:
322
+ """Returns True if a row was deleted."""
323
+ with self._conn() as conn:
324
+ self._ensure_watched_bots_table(conn)
325
+ cur = conn.execute("DELETE FROM watched_bots WHERE bot_id = ?", (bot_id,))
326
+ return cur.rowcount > 0
327
+
328
+ def is_watched_bot(self, bot_id: str) -> bool:
329
+ with self._conn() as conn:
330
+ self._ensure_watched_bots_table(conn)
331
+ return conn.execute(
332
+ "SELECT 1 FROM watched_bots WHERE bot_id = ?", (bot_id,)
333
+ ).fetchone() is not None
334
+
335
+ def get_watched_bots(self) -> list[dict]:
336
+ with self._conn() as conn:
337
+ self._ensure_watched_bots_table(conn)
338
+ rows = conn.execute(
339
+ "SELECT * FROM watched_bots ORDER BY added_at"
340
+ ).fetchall()
341
+ return [dict(r) for r in rows]
@@ -36,12 +36,9 @@ WORKSPACE_DIR=./workspace
36
36
  # Note: requires conversations:read scope on the Slack App if using channel name
37
37
  # SLACK_CHANNEL=devops-sentinel
38
38
 
39
- # Passive bot watcher (optional) Sentinel silently monitors these channels for error
40
- # reports from other bots (e.g. an app alerting bot on a secured server you can't SSH into).
41
- # When it detects an error message it auto-queues an issue no @Sentinel mention needed.
42
- # It reacts with 👀 so the team sees Sentinel noticed it.
43
- # Comma-separated list of channel names or IDs.
44
- # SLACK_WATCH_CHANNELS=alerts-1881, errors-prod
45
- # Optional: only watch messages from specific bot IDs (comma-separated Slack bot IDs).
46
- # Omit to watch all bots in the channel.
39
+ # Passive bot watcher — seed the watch list on startup with known bot IDs.
40
+ # Sentinel will passively queue every message from these bots as issues (no @mention needed).
41
+ # You can also add bots at runtime: "@Sentinel listen to @alertbot for project 1881"
42
+ # Use "@Sentinel list watched bots" to see the current list.
43
+ # Comma-separated list of Slack bot IDs (starts with B, not U).
47
44
  # SLACK_WATCH_BOT_IDS=B12345678, B87654321