@misterhuydo/sentinel 1.0.15 → 1.0.16

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-21T17:34:17.867Z
1
+ 2026-03-21T18:10:35.571Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-21T17:41:53.418Z",
3
- "checkpoint_at": "2026-03-21T17:41:53.419Z",
2
+ "message": "Auto-checkpoint at 2026-03-21T17:52:09.310Z",
3
+ "checkpoint_at": "2026-03-21T17:52:09.311Z",
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.15",
3
+ "version": "1.0.16",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -58,6 +58,7 @@ class SentinelConfig:
58
58
  upgrade_check_hours: int = 6 # hours between npm upgrade checks
59
59
  slack_bot_token: str = "" # xoxb-...
60
60
  slack_app_token: str = "" # xapp-... (Socket Mode)
61
+ slack_channel: str = "" # optional: restrict to one channel ID or name
61
62
 
62
63
 
63
64
  @dataclass
@@ -149,6 +150,7 @@ class ConfigLoader:
149
150
  c.upgrade_check_hours = int(d.get("UPGRADE_CHECK_HOURS", 6))
150
151
  c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
151
152
  c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
153
+ c.slack_channel = d.get("SLACK_CHANNEL", "")
152
154
  self.sentinel = c
153
155
 
154
156
  def _load_log_sources(self):
@@ -1,228 +1,261 @@
1
- """
2
- slack_bot.py — Slack integration for Sentinel Boss.
3
-
4
- One DevOps member is handled at a time per Sentinel instance.
5
- Others are queued and notified of their position. When the current
6
- session finishes, the next person is picked up automatically.
7
-
8
- Setup (api.slack.com):
9
- 1. Create a Slack App → Enable Socket Mode → copy App-Level Token (xapp-...)
10
- 2. Add Bot Token Scopes: app_mentions:read, chat:write, im:history,
11
- channels:history, users:read
12
- 3. Enable Events: app_mention, message.im, message.channels
13
- 4. Install to workspace → copy Bot Token (xoxb-...)
14
- 5. Add to sentinel.properties:
15
- SLACK_BOT_TOKEN=xoxb-...
16
- SLACK_APP_TOKEN=xapp-...
17
- """
18
-
19
- import asyncio
20
- import logging
21
- from dataclasses import dataclass, field
22
- from typing import Optional
23
-
24
- from .sentinel_boss import handle_message
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- # ── Session and Queue ─────────────────────────────────────────────────────────
30
-
31
- @dataclass
32
- class _Session:
33
- user_id: str
34
- user_name: str
35
- channel: str
36
- history: list = field(default_factory=list)
37
-
38
-
39
- class _Queue:
40
- """
41
- Global sequential queue for a Sentinel instance.
42
- One member at a time; others wait.
43
- """
44
-
45
- def __init__(self):
46
- self._lock: asyncio.Lock = asyncio.Lock()
47
- self._active: Optional[_Session] = None
48
- self._waiting: list[tuple[_Session, str]] = [] # (session, first_message)
49
-
50
- async def try_activate(
51
- self, user_id: str, user_name: str, channel: str
52
- ) -> tuple[str, int, Optional[_Session]]:
53
- """
54
- Returns:
55
- ('active', 0, session) — slot is free, session started
56
- ('continuing', 0, session) — same user already has the slot
57
- ('queued', pos, None) — added to wait queue at position pos
58
- """
59
- async with self._lock:
60
- if self._active is None:
61
- self._active = _Session(user_id, user_name, channel)
62
- return "active", 0, self._active
63
-
64
- if self._active.user_id == user_id:
65
- return "continuing", 0, self._active
66
-
67
- # Deduplicate — don't queue the same user twice
68
- if not any(s.user_id == user_id for s, _ in self._waiting):
69
- self._waiting.append((_Session(user_id, user_name, channel), ""))
70
-
71
- pos = next(
72
- i + 1
73
- for i, (s, _) in enumerate(self._waiting)
74
- if s.user_id == user_id
75
- )
76
- return "queued", pos, None
77
-
78
- async def update_waiting_message(self, user_id: str, message: str):
79
- """Overwrite the first message for a queued user (they may rephrase while waiting)."""
80
- async with self._lock:
81
- for i, (s, _) in enumerate(self._waiting):
82
- if s.user_id == user_id:
83
- self._waiting[i] = (s, message)
84
- return
85
-
86
- async def complete(self) -> Optional[tuple[_Session, str]]:
87
- """Release the active slot. Returns (next_session, first_message) or None."""
88
- async with self._lock:
89
- prev = self._active
90
- self._active = None
91
- if self._waiting:
92
- session, msg = self._waiting.pop(0)
93
- self._active = session
94
- return session, msg
95
- return None
96
-
97
- def active_user_name(self) -> Optional[str]:
98
- return self._active.user_name if self._active else None
99
-
100
-
101
- _queue = _Queue()
102
-
103
-
104
- # ── Slack bot ─────────────────────────────────────────────────────────────────
105
-
106
- async def run_slack_bot(cfg_loader, store) -> None:
107
- """
108
- Start Slack Bolt in Socket Mode as an asyncio background task.
109
- Exits silently if tokens are missing or slack-bolt is not installed.
110
- """
111
- try:
112
- from slack_bolt.async_app import AsyncApp
113
- from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
114
- except ImportError:
115
- logger.warning(
116
- "slack-bolt not installed — Slack bot disabled. "
117
- "Run: pip install slack-bolt"
118
- )
119
- return
120
-
121
- cfg = cfg_loader.sentinel
122
- if not cfg.slack_bot_token or not cfg.slack_app_token:
123
- logger.info("SLACK_BOT_TOKEN / SLACK_APP_TOKEN not set — Slack bot disabled")
124
- return
125
-
126
- app = AsyncApp(token=cfg.slack_bot_token)
127
-
128
- # ── Event handlers ────────────────────────────────────────────────────────
129
-
130
- @app.event("app_mention")
131
- async def on_mention(event, client):
132
- await _dispatch(event, client, cfg_loader, store)
133
-
134
- @app.event("message")
135
- async def on_message(event, client):
136
- # Handle DMs only (channel_type == "im"); ignore bot messages
137
- if event.get("channel_type") == "im" and not event.get("bot_id"):
138
- await _dispatch(event, client, cfg_loader, store)
139
-
140
- # ── Start ─────────────────────────────────────────────────────────────────
141
-
142
- handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
143
- logger.info("Sentinel Boss connected to Slack (Socket Mode)")
144
- await handler.start_async()
145
-
146
-
147
- # ── Dispatcher ────────────────────────────────────────────────────────────────
148
-
149
- async def _dispatch(event: dict, client, cfg_loader, store) -> None:
150
- user_id = event.get("user", "")
151
- channel = event.get("channel", "")
152
- text = _strip_mention(event.get("text", "")).strip()
153
-
154
- if not text:
155
- return
156
-
157
- user_name = await _resolve_name(client, user_id)
158
-
159
- status, pos, session = await _queue.try_activate(user_id, user_name, channel)
160
-
161
- if status == "queued":
162
- await _queue.update_waiting_message(user_id, text)
163
- active = _queue.active_user_name() or "someone"
164
- await _post(client, channel,
165
- f"I'm working with *{active}* right now. "
166
- f"You're *#{pos}* in the queue — I'll come to you when done. :hourglass_flowing_sand:"
167
- )
168
- return
169
-
170
- # Active or continuing process the turn
171
- await _run_turn(session, text, client, cfg_loader, store)
172
-
173
-
174
- # ── Turn processor ────────────────────────────────────────────────────────────
175
-
176
- async def _run_turn(session: _Session, message: str, client, cfg_loader, store) -> None:
177
- channel = session.channel
178
-
179
- # Typing indicator
180
- await _post(client, channel, "_thinking..._")
181
-
182
- try:
183
- reply, is_done = await handle_message(
184
- message, session.history, cfg_loader, store
185
- )
186
- except Exception as e:
187
- logger.exception("Sentinel Boss error: %s", e)
188
- await _post(client, channel, f":warning: Unhandled error: {e}")
189
- is_done = True
190
-
191
- await _post(client, channel, reply)
192
-
193
- if is_done:
194
- next_item = await _queue.complete()
195
- if next_item:
196
- next_session, first_msg = next_item
197
- await _post(
198
- client, channel,
199
- f":white_check_mark: Done with *{session.user_name}*. "
200
- f"<@{next_session.user_id}> — you're up!"
201
- )
202
- if first_msg:
203
- # Process their first message immediately
204
- await _run_turn(next_session, first_msg, client, cfg_loader, store)
205
-
206
-
207
- # ── Helpers ───────────────────────────────────────────────────────────────────
208
-
209
- async def _post(client, channel: str, text: str) -> None:
210
- try:
211
- await client.chat_postMessage(channel=channel, text=text)
212
- except Exception as e:
213
- logger.warning("Slack post failed: %s", e)
214
-
215
-
216
- async def _resolve_name(client, user_id: str) -> str:
217
- try:
218
- info = await client.users_info(user=user_id)
219
- profile = info["user"]["profile"]
220
- return profile.get("display_name") or profile.get("real_name") or user_id
221
- except Exception:
222
- return user_id
223
-
224
-
225
- def _strip_mention(text: str) -> str:
226
- """Remove leading <@BOTID> mention from message text."""
227
- import re
228
- return re.sub(r"^<@[A-Z0-9]+>\s*", "", text)
1
+ """
2
+ slack_bot.py — Slack integration for Sentinel Boss.
3
+
4
+ One DevOps member is handled at a time per Sentinel instance.
5
+ Others are queued and notified of their position. When the current
6
+ session finishes, the next person is picked up automatically.
7
+
8
+ Setup (api.slack.com):
9
+ 1. Create a Slack App → Enable Socket Mode → copy App-Level Token (xapp-...)
10
+ 2. Add Bot Token Scopes: app_mentions:read, chat:write, im:history,
11
+ channels:history, users:read
12
+ 3. Enable Events: app_mention, message.im, message.channels
13
+ 4. Install to workspace → copy Bot Token (xoxb-...)
14
+ 5. Add to sentinel.properties:
15
+ SLACK_BOT_TOKEN=xoxb-...
16
+ SLACK_APP_TOKEN=xapp-...
17
+ """
18
+
19
+ import asyncio
20
+ import logging
21
+ from dataclasses import dataclass, field
22
+ from typing import Optional
23
+
24
+ from .sentinel_boss import handle_message
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # ── Session and Queue ─────────────────────────────────────────────────────────
30
+
31
+ @dataclass
32
+ class _Session:
33
+ user_id: str
34
+ user_name: str
35
+ channel: str
36
+ history: list = field(default_factory=list)
37
+
38
+
39
+ class _Queue:
40
+ """
41
+ Global sequential queue for a Sentinel instance.
42
+ One member at a time; others wait.
43
+ """
44
+
45
+ def __init__(self):
46
+ self._lock: asyncio.Lock = asyncio.Lock()
47
+ self._active: Optional[_Session] = None
48
+ self._waiting: list[tuple[_Session, str]] = [] # (session, first_message)
49
+
50
+ async def try_activate(
51
+ self, user_id: str, user_name: str, channel: str
52
+ ) -> tuple[str, int, Optional[_Session]]:
53
+ """
54
+ Returns:
55
+ ('active', 0, session) — slot is free, session started
56
+ ('continuing', 0, session) — same user already has the slot
57
+ ('queued', pos, None) — added to wait queue at position pos
58
+ """
59
+ async with self._lock:
60
+ if self._active is None:
61
+ self._active = _Session(user_id, user_name, channel)
62
+ return "active", 0, self._active
63
+
64
+ if self._active.user_id == user_id:
65
+ return "continuing", 0, self._active
66
+
67
+ # Deduplicate — don't queue the same user twice
68
+ if not any(s.user_id == user_id for s, _ in self._waiting):
69
+ self._waiting.append((_Session(user_id, user_name, channel), ""))
70
+
71
+ pos = next(
72
+ i + 1
73
+ for i, (s, _) in enumerate(self._waiting)
74
+ if s.user_id == user_id
75
+ )
76
+ return "queued", pos, None
77
+
78
+ async def update_waiting_message(self, user_id: str, message: str):
79
+ """Overwrite the first message for a queued user (they may rephrase while waiting)."""
80
+ async with self._lock:
81
+ for i, (s, _) in enumerate(self._waiting):
82
+ if s.user_id == user_id:
83
+ self._waiting[i] = (s, message)
84
+ return
85
+
86
+ async def complete(self) -> Optional[tuple[_Session, str]]:
87
+ """Release the active slot. Returns (next_session, first_message) or None."""
88
+ async with self._lock:
89
+ prev = self._active
90
+ self._active = None
91
+ if self._waiting:
92
+ session, msg = self._waiting.pop(0)
93
+ self._active = session
94
+ return session, msg
95
+ return None
96
+
97
+ def active_user_name(self) -> Optional[str]:
98
+ return self._active.user_name if self._active else None
99
+
100
+
101
+ _queue = _Queue()
102
+
103
+
104
+ # ── Slack bot ─────────────────────────────────────────────────────────────────
105
+
106
+ async def run_slack_bot(cfg_loader, store) -> None:
107
+ """
108
+ Start Slack Bolt in Socket Mode as an asyncio background task.
109
+ Exits silently if tokens are missing or slack-bolt is not installed.
110
+ """
111
+ try:
112
+ from slack_bolt.async_app import AsyncApp
113
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
114
+ except ImportError:
115
+ logger.warning(
116
+ "slack-bolt not installed — Slack bot disabled. "
117
+ "Run: pip install slack-bolt"
118
+ )
119
+ return
120
+
121
+ cfg = cfg_loader.sentinel
122
+ if not cfg.slack_bot_token or not cfg.slack_app_token:
123
+ logger.info("SLACK_BOT_TOKEN / SLACK_APP_TOKEN not set — Slack bot disabled")
124
+ return
125
+
126
+ if cfg.slack_channel:
127
+ logger.info("Sentinel Boss restricted to channel: %s", cfg.slack_channel)
128
+
129
+ app = AsyncApp(token=cfg.slack_bot_token)
130
+
131
+ # ── Channel resolver — converts #name to ID lazily on first event ─────────
132
+ _allowed_id: list = [] # single-element list used as mutable cell
133
+
134
+ async def _resolve_allowed(client):
135
+ raw = cfg.slack_channel.lstrip("#")
136
+ if not raw:
137
+ return
138
+ # Already a Slack channel ID (e.g. C01AB2CD3EF)
139
+ if raw.upper().startswith("C") and len(raw) >= 9:
140
+ _allowed_id.append(raw.upper())
141
+ return
142
+ try:
143
+ resp = await client.conversations_list(
144
+ types="public_channel,private_channel", limit=1000
145
+ )
146
+ for ch in (resp.get("channels") or []):
147
+ if ch.get("name") == raw:
148
+ _allowed_id.append(ch["id"])
149
+ logger.info("Boss channel resolved: #%s → %s", raw, ch["id"])
150
+ return
151
+ logger.warning("SLACK_CHANNEL '%s' not found — responding everywhere", cfg.slack_channel)
152
+ except Exception as e:
153
+ logger.warning("Could not resolve SLACK_CHANNEL '%s': %s", cfg.slack_channel, e)
154
+
155
+ def _allowed(channel: str) -> bool:
156
+ return not _allowed_id or channel in _allowed_id
157
+
158
+ # ── Event handlers ────────────────────────────────────────────────────────
159
+
160
+ @app.event("app_mention")
161
+ async def on_mention(event, client):
162
+ if cfg.slack_channel and not _allowed_id:
163
+ await _resolve_allowed(client)
164
+ if _allowed(event.get("channel", "")):
165
+ await _dispatch(event, client, cfg_loader, store)
166
+
167
+ @app.event("message")
168
+ async def on_message(event, client):
169
+ # DMs are always allowed regardless of SLACK_CHANNEL restriction
170
+ if event.get("channel_type") == "im" and not event.get("bot_id"):
171
+ await _dispatch(event, client, cfg_loader, store)
172
+
173
+ # ── Start ─────────────────────────────────────────────────────────────────
174
+
175
+ handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
176
+ logger.info("Sentinel Boss connected to Slack (Socket Mode)")
177
+ await handler.start_async()
178
+
179
+
180
+ # ── Dispatcher ────────────────────────────────────────────────────────────────
181
+
182
+ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
183
+ user_id = event.get("user", "")
184
+ channel = event.get("channel", "")
185
+ text = _strip_mention(event.get("text", "")).strip()
186
+
187
+ if not text:
188
+ return
189
+
190
+ user_name = await _resolve_name(client, user_id)
191
+
192
+ status, pos, session = await _queue.try_activate(user_id, user_name, channel)
193
+
194
+ if status == "queued":
195
+ await _queue.update_waiting_message(user_id, text)
196
+ active = _queue.active_user_name() or "someone"
197
+ await _post(client, channel,
198
+ f"I'm working with *{active}* right now. "
199
+ f"You're *#{pos}* in the queue — I'll come to you when done. :hourglass_flowing_sand:"
200
+ )
201
+ return
202
+
203
+ # Active or continuing process the turn
204
+ await _run_turn(session, text, client, cfg_loader, store)
205
+
206
+
207
+ # ── Turn processor ────────────────────────────────────────────────────────────
208
+
209
+ async def _run_turn(session: _Session, message: str, client, cfg_loader, store) -> None:
210
+ channel = session.channel
211
+
212
+ # Typing indicator
213
+ await _post(client, channel, "_thinking..._")
214
+
215
+ try:
216
+ reply, is_done = await handle_message(
217
+ message, session.history, cfg_loader, store
218
+ )
219
+ except Exception as e:
220
+ logger.exception("Sentinel Boss error: %s", e)
221
+ await _post(client, channel, f":warning: Unhandled error: {e}")
222
+ is_done = True
223
+
224
+ await _post(client, channel, reply)
225
+
226
+ if is_done:
227
+ next_item = await _queue.complete()
228
+ if next_item:
229
+ next_session, first_msg = next_item
230
+ await _post(
231
+ client, channel,
232
+ f":white_check_mark: Done with *{session.user_name}*. "
233
+ f"<@{next_session.user_id}> — you're up!"
234
+ )
235
+ if first_msg:
236
+ # Process their first message immediately
237
+ await _run_turn(next_session, first_msg, client, cfg_loader, store)
238
+
239
+
240
+ # ── Helpers ───────────────────────────────────────────────────────────────────
241
+
242
+ async def _post(client, channel: str, text: str) -> None:
243
+ try:
244
+ await client.chat_postMessage(channel=channel, text=text)
245
+ except Exception as e:
246
+ logger.warning("Slack post failed: %s", e)
247
+
248
+
249
+ async def _resolve_name(client, user_id: str) -> str:
250
+ try:
251
+ info = await client.users_info(user=user_id)
252
+ profile = info["user"]["profile"]
253
+ return profile.get("display_name") or profile.get("real_name") or user_id
254
+ except Exception:
255
+ return user_id
256
+
257
+
258
+ def _strip_mention(text: str) -> str:
259
+ """Remove leading <@BOTID> mention from message text."""
260
+ import re
261
+ return re.sub(r"^<@[A-Z0-9]+>\s*", "", text)
@@ -25,3 +25,7 @@ WORKSPACE_DIR=./workspace
25
25
  # Then install to workspace and paste both tokens here.
26
26
  # SLACK_BOT_TOKEN=xoxb-...
27
27
  # SLACK_APP_TOKEN=xapp-...
28
+ # Restrict Sentinel Boss to one channel (optional — omit to allow all channels + DMs)
29
+ # Use the channel name (e.g. devops-sentinel) or the Slack channel ID (e.g. C01AB2CD3EF)
30
+ # Note: requires conversations:read scope on the Slack App if using channel name
31
+ # SLACK_CHANNEL=devops-sentinel