@misterhuydo/sentinel 1.0.50 → 1.0.52

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-22T09:54:45.729Z
1
+ 2026-03-22T10:41:20.633Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-22T10:07:25.081Z",
3
- "checkpoint_at": "2026-03-22T10:07:25.082Z",
2
+ "message": "Auto-checkpoint at 2026-03-22T10:44:11.780Z",
3
+ "checkpoint_at": "2026-03-22T10:44:11.781Z",
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.50",
3
+ "version": "1.0.52",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -60,6 +60,8 @@ 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
65
  project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
64
66
 
65
67
 
@@ -153,6 +155,8 @@ class ConfigLoader:
153
155
  c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
154
156
  c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
155
157
  c.slack_channel = d.get("SLACK_CHANNEL", "")
158
+ c.slack_watch_channels = _csv(d.get("SLACK_WATCH_CHANNELS", ""))
159
+ c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
156
160
  c.project_name = d.get("PROJECT_NAME", "")
157
161
  self.sentinel = c
158
162
 
@@ -124,7 +124,7 @@ def generate_fix(
124
124
  env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
125
125
  try:
126
126
  result = subprocess.run(
127
- [cfg.claude_code_bin, "--print", prompt],
127
+ [cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt],
128
128
  capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
129
129
  )
130
130
  except subprocess.TimeoutExpired:
@@ -771,7 +771,7 @@ async def _handle_with_cli(
771
771
 
772
772
  try:
773
773
  result = subprocess.run(
774
- [cfg.claude_code_bin, "--print", prompt],
774
+ [cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt],
775
775
  capture_output=True, text=True, timeout=180, env=env,
776
776
  )
777
777
  output = (result.stdout or "").strip()
@@ -164,19 +164,133 @@ 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
199
+
167
200
  @app.event("message")
168
201
  async def on_message(event, client):
169
- # DMs are always allowed regardless of SLACK_CHANNEL restriction
202
+ # DMs from humans Boss conversation
170
203
  if event.get("channel_type") == "im" and not event.get("bot_id"):
171
204
  await _dispatch(event, client, cfg_loader, store)
205
+ return
206
+
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)
172
213
 
173
214
  # ── Start ─────────────────────────────────────────────────────────────────
174
215
 
175
216
  handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
176
217
  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)
177
220
  await handler.start_async()
178
221
 
179
222
 
223
+ # ── Bot-message watcher ───────────────────────────────────────────────────────
224
+
225
+ import re as _re
226
+ import uuid as _uuid
227
+ from pathlib import Path as _Path
228
+
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
+
244
+ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
245
+ """
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.
248
+ """
249
+ cfg = cfg_loader.sentinel
250
+ bot_id = event.get("bot_id", "")
251
+ channel = event.get("channel", "")
252
+ ts = event.get("ts", "")
253
+
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:
256
+ return
257
+
258
+ # Flatten text (handle block-kit attachments too)
259
+ text = event.get("text", "")
260
+ for att in event.get("attachments", []):
261
+ text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
262
+ text = text.strip()
263
+
264
+ if not text or not _looks_like_error(text):
265
+ return
266
+
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)
272
+
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"
277
+ content = (
278
+ f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
279
+ f"SLACK_TS: {ts}\n\n"
280
+ f"{text}"
281
+ )
282
+ (issues_dir / fname).write_text(content, encoding="utf-8")
283
+ _Path("SENTINEL_POLL_NOW").touch()
284
+ logger.info("Bot watcher queued issue from %s: %s", bot_id, fname)
285
+
286
+ # React with 👀 so the team knows Sentinel noticed it
287
+ if ts:
288
+ try:
289
+ await client.reactions_add(channel=channel, timestamp=ts, name="eyes")
290
+ except Exception:
291
+ pass # reactions:write scope may not be granted — non-fatal
292
+
293
+
180
294
  # ── Dispatcher ────────────────────────────────────────────────────────────────
181
295
 
182
296
  async def _dispatch(event: dict, client, cfg_loader, store) -> None:
@@ -273,3 +273,29 @@ class StateStore:
273
273
  if row:
274
274
  return datetime.fromisoformat(row["sent_at"])
275
275
  return None
276
+
277
+ # ── Dedup store (used by Slack bot watcher) ───────────────────────────────
278
+
279
+ def is_seen_dedup(self, key: str) -> bool:
280
+ """Return True if this dedup key has already been processed."""
281
+ with self._conn() as conn:
282
+ conn.execute(
283
+ "CREATE TABLE IF NOT EXISTS dedup "
284
+ "(key TEXT PRIMARY KEY, seen_at TEXT)"
285
+ )
286
+ row = conn.execute(
287
+ "SELECT key FROM dedup WHERE key = ?", (key,)
288
+ ).fetchone()
289
+ return row is not None
290
+
291
+ def mark_seen_dedup(self, key: str):
292
+ """Record a dedup key so it won't be processed again."""
293
+ with self._conn() as conn:
294
+ conn.execute(
295
+ "CREATE TABLE IF NOT EXISTS dedup "
296
+ "(key TEXT PRIMARY KEY, seen_at TEXT)"
297
+ )
298
+ conn.execute(
299
+ "INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
300
+ (key, _now()),
301
+ )
@@ -27,7 +27,7 @@ WORKSPACE_DIR=./workspace
27
27
 
28
28
  # Slack Bot (optional) — Sentinel Boss conversational interface
29
29
  # Create a Slack App at api.slack.com, enable Socket Mode, add scopes:
30
- # app_mentions:read, chat:write, im:history, channels:history, users:read
30
+ # app_mentions:read, chat:write, im:history, channels:history, users:read, reactions:write
31
31
  # Then install to workspace and paste both tokens here.
32
32
  # SLACK_BOT_TOKEN=xoxb-...
33
33
  # SLACK_APP_TOKEN=xapp-...
@@ -35,3 +35,13 @@ WORKSPACE_DIR=./workspace
35
35
  # Use the channel name (e.g. devops-sentinel) or the Slack channel ID (e.g. C01AB2CD3EF)
36
36
  # Note: requires conversations:read scope on the Slack App if using channel name
37
37
  # SLACK_CHANNEL=devops-sentinel
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.
47
+ # SLACK_WATCH_BOT_IDS=B12345678, B87654321