@misterhuydo/sentinel 1.0.13 → 1.0.15

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:01:47.015Z
1
+ 2026-03-21T17:34:17.867Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-21T17:16:10.950Z",
3
- "checkpoint_at": "2026-03-21T17:16:10.951Z",
2
+ "message": "Auto-checkpoint at 2026-03-21T17:41:53.418Z",
3
+ "checkpoint_at": "2026-03-21T17:41:53.419Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/lib/generate.js CHANGED
@@ -5,18 +5,24 @@ const path = require('path');
5
5
 
6
6
  // ── Per-project files ─────────────────────────────────────────────────────────
7
7
 
8
- function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '') {
8
+ function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '', slackTokens = {}) {
9
9
  const configDir = path.join(projectDir, 'config', 'log-configs');
10
10
  const repoDir = path.join(projectDir, 'config', 'repo-configs');
11
11
  fs.ensureDirSync(configDir);
12
12
  fs.ensureDirSync(repoDir);
13
13
 
14
14
  const tplDir = path.join(__dirname, '..', 'templates');
15
- // Per-project sentinel.properties: MAILS, GITHUB_TOKEN, optional API key
15
+ // Per-project sentinel.properties: MAILS, GITHUB_TOKEN, optional API key, optional Slack
16
16
  let sentinelProps = fs.readFileSync(path.join(tplDir, 'sentinel.properties'), 'utf8');
17
17
  if (anthropicKey) {
18
18
  sentinelProps = sentinelProps.replace(/^# ANTHROPIC_API_KEY=.*/m, `ANTHROPIC_API_KEY=${anthropicKey}`);
19
19
  }
20
+ if (slackTokens.botToken) {
21
+ sentinelProps = sentinelProps.replace(/^# SLACK_BOT_TOKEN=.*/m, `SLACK_BOT_TOKEN=${slackTokens.botToken}`);
22
+ }
23
+ if (slackTokens.appToken) {
24
+ sentinelProps = sentinelProps.replace(/^# SLACK_APP_TOKEN=.*/m, `SLACK_APP_TOKEN=${slackTokens.appToken}`);
25
+ }
20
26
  fs.writeFileSync(path.join(projectDir, 'config', 'sentinel.properties'), sentinelProps);
21
27
  fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
22
28
  fs.copySync(path.join(tplDir, 'repo-configs', '_example.properties'), path.join(repoDir, '_example.properties'));
package/lib/init.js CHANGED
@@ -68,9 +68,27 @@ module.exports = async function init() {
68
68
  message: 'SMTP host',
69
69
  initial: 'smtp.gmail.com',
70
70
  },
71
+ {
72
+ type: 'confirm',
73
+ name: 'setupSlack',
74
+ message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
75
+ initial: false,
76
+ },
77
+ {
78
+ type: prev => prev ? 'password' : null,
79
+ name: 'slackBotToken',
80
+ message: 'Slack Bot Token (xoxb-...)',
81
+ validate: v => v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
82
+ },
83
+ {
84
+ type: (_, { slackBotToken }) => slackBotToken ? 'password' : null,
85
+ name: 'slackAppToken',
86
+ message: 'Slack App-Level Token (xapp-...)',
87
+ validate: v => v.startsWith('xapp-') ? true : 'Should start with xapp-',
88
+ },
71
89
  ], { onCancel: () => process.exit(0) });
72
90
 
73
- const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost } = answers;
91
+ const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
74
92
  const codeDir = path.join(workspace, 'code');
75
93
 
76
94
  // ── Python ──────────────────────────────────────────────────────────────────
@@ -123,6 +141,15 @@ module.exports = async function init() {
123
141
  info('Skipping auth — start.sh will prompt for login if needed');
124
142
  }
125
143
 
144
+ // ── Slack Bot ────────────────────────────────────────────────────────────────
145
+ if (slackBotToken && slackAppToken) {
146
+ step('Slack Bot (Sentinel Boss)…');
147
+ ok('Tokens will be written to the project sentinel.properties');
148
+ info('Sentinel Boss starts automatically when the project starts');
149
+ } else if (setupSlack) {
150
+ warn('Slack tokens not provided — add them to config/sentinel.properties later');
151
+ }
152
+
126
153
  // ── Workspace structure ─────────────────────────────────────────────────────
127
154
  step('Creating workspace…');
128
155
  fs.ensureDirSync(workspace);
@@ -132,7 +159,7 @@ module.exports = async function init() {
132
159
  if (example) {
133
160
  step('Creating example project…');
134
161
  const exampleDir = path.join(workspace, 'my-project');
135
- writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '');
162
+ writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '', { botToken: slackBotToken || '', appToken: slackAppToken || '' });
136
163
  ok(`Example project: ${exampleDir}`);
137
164
  }
138
165
 
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
- {
2
- "name": "@misterhuydo/sentinel",
3
- "version": "1.0.13",
4
- "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
- "bin": {
6
- "sentinel": "./bin/sentinel.js"
7
- },
8
- "scripts": {
9
- "prepublishOnly": "node scripts/bundle.js"
10
- },
11
- "dependencies": {
12
- "chalk": "^4.1.2",
13
- "fs-extra": "^11.2.0",
14
- "prompts": "^2.4.2"
15
- },
16
- "engines": {
17
- "node": ">=16"
18
- },
19
- "author": "misterhuydo",
20
- "license": "MIT"
21
- }
1
+ {
2
+ "name": "@misterhuydo/sentinel",
3
+ "version": "1.0.15",
4
+ "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
+ "bin": {
6
+ "sentinel": "./bin/sentinel.js"
7
+ },
8
+ "scripts": {
9
+ "prepublishOnly": "node scripts/bundle.js"
10
+ },
11
+ "dependencies": {
12
+ "chalk": "^4.1.2",
13
+ "fs-extra": "^11.2.0",
14
+ "prompts": "^2.4.2"
15
+ },
16
+ "engines": {
17
+ "node": ">=16"
18
+ },
19
+ "author": "misterhuydo",
20
+ "license": "MIT"
21
+ }
@@ -3,3 +3,5 @@ schedule>=1.2
3
3
  python-dotenv>=1.0
4
4
  requests>=2.31
5
5
  jinja2>=3.1
6
+ anthropic>=0.27
7
+ slack-bolt>=1.18
@@ -56,6 +56,8 @@ class SentinelConfig:
56
56
  auto_upgrade: bool = True # auto-upgrade on new patch versions
57
57
  version_pin: str = "" # if set, never upgrade beyond this version
58
58
  upgrade_check_hours: int = 6 # hours between npm upgrade checks
59
+ slack_bot_token: str = "" # xoxb-...
60
+ slack_app_token: str = "" # xapp-... (Socket Mode)
59
61
 
60
62
 
61
63
  @dataclass
@@ -145,6 +147,8 @@ class ConfigLoader:
145
147
  c.auto_upgrade = d.get("AUTO_UPGRADE", "true").lower() != "false"
146
148
  c.version_pin = d.get("VERSION_PIN", "")
147
149
  c.upgrade_check_hours = int(d.get("UPGRADE_CHECK_HOURS", 6))
150
+ c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
151
+ c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
148
152
  self.sentinel = c
149
153
 
150
154
  def _load_log_sources(self):
@@ -563,6 +563,9 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
563
563
  asyncio.ensure_future(_config_poll_loop(cfg_loader))
564
564
  if cfg_loader.sentinel.auto_upgrade:
565
565
  asyncio.ensure_future(_upgrade_check_loop(cfg_loader))
566
+ if cfg_loader.sentinel.slack_bot_token:
567
+ from .slack_bot import run_slack_bot
568
+ asyncio.ensure_future(run_slack_bot(cfg_loader, store))
566
569
 
567
570
  while True:
568
571
  try:
@@ -0,0 +1,297 @@
1
+ """
2
+ sentinel_boss.py — Claude-backed Sentinel Boss.
3
+
4
+ Claude acts as the boss: reads project state, decides on actions,
5
+ executes them via tool use, and responds naturally. One agentic loop
6
+ per turn — Claude may call multiple tools before replying.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ── System prompt ────────────────────────────────────────────────────────────
20
+
21
+ _SYSTEM = """\
22
+ You are Sentinel Boss — the AI interface for Sentinel, a 24/7 autonomous DevOps agent.
23
+
24
+ Sentinel watches production logs, detects errors, generates code fixes via Claude Code,
25
+ and opens GitHub PRs for admin review (or pushes directly if AUTO_PUBLISH=true).
26
+
27
+ Your job:
28
+ - Understand what the DevOps engineer needs in natural language
29
+ - Query Sentinel's live state (errors, fixes, open PRs) on their behalf
30
+ - Create issue reports when asked to investigate or fix something
31
+ - Control Sentinel (pause/resume) when asked
32
+ - Give honest, concise answers — you know this system inside out
33
+
34
+ Tone: direct, professional, like a senior engineer who owns the system.
35
+ Don't pad responses. Don't say "Great question!" or "Certainly!".
36
+ If you don't know something, use a tool to find out before saying you don't know.
37
+
38
+ When the engineer's request is fully handled, end your LAST message with the token: [DONE]
39
+ If you need a follow-up from them, do NOT include [DONE] — wait for their next message.
40
+ """
41
+
42
+ # ── Tool definitions ─────────────────────────────────────────────────────────
43
+
44
+ _TOOLS = [
45
+ {
46
+ "name": "get_status",
47
+ "description": (
48
+ "Get recent errors, fixes applied, fixes pending review, and open PRs. "
49
+ "Use for: 'what happened today?', 'any issues?', 'how are things?', "
50
+ "'what are the open PRs?', 'did sentinel fix anything?'"
51
+ ),
52
+ "input_schema": {
53
+ "type": "object",
54
+ "properties": {
55
+ "hours": {
56
+ "type": "integer",
57
+ "description": "Look-back window in hours (default 24)",
58
+ "default": 24,
59
+ },
60
+ },
61
+ },
62
+ },
63
+ {
64
+ "name": "create_issue",
65
+ "description": (
66
+ "Queue a fix request for Sentinel to investigate on the next poll cycle. "
67
+ "Use whenever the engineer reports a bug, customer complaint, or asks you "
68
+ "to look into something specific. Include every detail they gave you."
69
+ ),
70
+ "input_schema": {
71
+ "type": "object",
72
+ "properties": {
73
+ "description": {
74
+ "type": "string",
75
+ "description": "Full problem description — everything the engineer told you",
76
+ },
77
+ "target_repo": {
78
+ "type": "string",
79
+ "description": "Repo name to assign to (omit to let Sentinel auto-route)",
80
+ },
81
+ },
82
+ "required": ["description"],
83
+ },
84
+ },
85
+ {
86
+ "name": "get_fix_details",
87
+ "description": "Get full details of a specific fix by fingerprint (8+ hex chars).",
88
+ "input_schema": {
89
+ "type": "object",
90
+ "properties": {
91
+ "fingerprint": {"type": "string"},
92
+ },
93
+ "required": ["fingerprint"],
94
+ },
95
+ },
96
+ {
97
+ "name": "list_pending_prs",
98
+ "description": "List all open Sentinel PRs awaiting admin review.",
99
+ "input_schema": {"type": "object", "properties": {}},
100
+ },
101
+ {
102
+ "name": "pause_sentinel",
103
+ "description": (
104
+ "Pause ALL Sentinel fix activity immediately. "
105
+ "Use when the engineer says 'pause', 'stop', 'freeze', or 'hold off'."
106
+ ),
107
+ "input_schema": {"type": "object", "properties": {}},
108
+ },
109
+ {
110
+ "name": "resume_sentinel",
111
+ "description": "Resume Sentinel fix activity after a pause.",
112
+ "input_schema": {"type": "object", "properties": {}},
113
+ },
114
+ ]
115
+
116
+
117
+ # ── Tool execution ────────────────────────────────────────────────────────────
118
+
119
+ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
120
+ if name == "get_status":
121
+ hours = int(inputs.get("hours", 24))
122
+ errors = store.get_recent_errors(hours)
123
+ fixes = store.get_recent_fixes(hours)
124
+ prs = store.get_open_prs()
125
+ top_errors = [
126
+ {
127
+ "message": e["message"][:120],
128
+ "count": e["count"],
129
+ "source": e["source"],
130
+ "last_seen": e["last_seen"],
131
+ }
132
+ for e in errors[:8]
133
+ ]
134
+ return json.dumps({
135
+ "window_hours": hours,
136
+ "errors_detected": len(errors),
137
+ "top_errors": top_errors,
138
+ "fixes_applied": sum(1 for f in fixes if f["status"] == "applied"),
139
+ "fixes_pending": sum(1 for f in fixes if f["status"] == "pending"),
140
+ "fixes_failed": sum(1 for f in fixes if f["status"] == "failed"),
141
+ "open_prs": [
142
+ {
143
+ "repo": p["repo_name"],
144
+ "branch": p["branch"],
145
+ "pr_url": p["pr_url"],
146
+ "age": p.get("timestamp", ""),
147
+ }
148
+ for p in prs
149
+ ],
150
+ "sentinel_paused": Path("SENTINEL_PAUSE").exists(),
151
+ })
152
+
153
+ if name == "create_issue":
154
+ description = inputs["description"]
155
+ target_repo = inputs.get("target_repo", "")
156
+ issues_dir = Path("issues")
157
+ issues_dir.mkdir(exist_ok=True)
158
+ fname = f"slack-{uuid.uuid4().hex[:8]}.txt"
159
+ content = (f"TARGET_REPO: {target_repo}\n\n" if target_repo else "") + description
160
+ (issues_dir / fname).write_text(content, encoding="utf-8")
161
+ logger.info("Boss created issue: %s", fname)
162
+ return json.dumps({
163
+ "status": "queued",
164
+ "file": fname,
165
+ "note": "Sentinel will pick this up on the next poll cycle.",
166
+ })
167
+
168
+ if name == "get_fix_details":
169
+ fp = inputs["fingerprint"]
170
+ fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
171
+ if not fix:
172
+ # Fallback: search recent fixes by prefix
173
+ recent = store.get_recent_fixes(hours=72)
174
+ fix = next((f for f in recent if f.get("fingerprint", "").startswith(fp)), None)
175
+ return json.dumps(fix or {"error": "not found"})
176
+
177
+ if name == "list_pending_prs":
178
+ prs = store.get_open_prs()
179
+ return json.dumps({
180
+ "count": len(prs),
181
+ "open_prs": [
182
+ {
183
+ "repo": p["repo_name"],
184
+ "branch": p["branch"],
185
+ "pr_url": p["pr_url"],
186
+ "timestamp": p.get("timestamp", ""),
187
+ }
188
+ for p in prs
189
+ ],
190
+ })
191
+
192
+ if name == "pause_sentinel":
193
+ Path("SENTINEL_PAUSE").touch()
194
+ logger.info("Boss: SENTINEL_PAUSE created")
195
+ return json.dumps({"status": "paused"})
196
+
197
+ if name == "resume_sentinel":
198
+ p = Path("SENTINEL_PAUSE")
199
+ if p.exists():
200
+ p.unlink()
201
+ logger.info("Boss: SENTINEL_PAUSE removed")
202
+ return json.dumps({"status": "resumed"})
203
+
204
+ return json.dumps({"error": f"unknown tool: {name}"})
205
+
206
+
207
+ # ── Main entry point ──────────────────────────────────────────────────────────
208
+
209
+ async def handle_message(
210
+ message: str,
211
+ history: list,
212
+ cfg_loader,
213
+ store,
214
+ ) -> tuple[str, bool]:
215
+ """
216
+ Process one user message through the Sentinel Boss (Claude with tool use).
217
+
218
+ Args:
219
+ message: The user's Slack message text.
220
+ history: Conversation history list — mutated in place (role/content dicts).
221
+ cfg_loader: ConfigLoader for repo/sentinel config.
222
+ store: StateStore for DB queries.
223
+
224
+ Returns:
225
+ (reply_text, is_done)
226
+ is_done=True → session complete, release the Slack queue slot.
227
+ is_done=False → waiting for user follow-up, keep the slot.
228
+ """
229
+ try:
230
+ import anthropic
231
+ except ImportError:
232
+ return (
233
+ ":warning: `anthropic` package not installed. Run: `pip install anthropic`",
234
+ True,
235
+ )
236
+
237
+ api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
238
+ if not api_key:
239
+ return (
240
+ ":warning: `ANTHROPIC_API_KEY` not configured in `sentinel.properties`.",
241
+ True,
242
+ )
243
+
244
+ client = anthropic.Anthropic(api_key=api_key)
245
+
246
+ # Build system context snapshot
247
+ paused = Path("SENTINEL_PAUSE").exists()
248
+ repos = list(cfg_loader.repos.keys())
249
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
250
+ system = (
251
+ _SYSTEM
252
+ + f"\n\nCurrent time: {ts}"
253
+ + f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
254
+ + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
255
+ )
256
+
257
+ history.append({"role": "user", "content": message})
258
+ messages = list(history)
259
+
260
+ # Agentic loop — Claude may call multiple tools before giving a final reply
261
+ while True:
262
+ response = client.messages.create(
263
+ model="claude-opus-4-6",
264
+ max_tokens=1024,
265
+ system=system,
266
+ tools=_TOOLS,
267
+ messages=messages,
268
+ )
269
+
270
+ text_parts = []
271
+ tool_blocks = []
272
+ for block in response.content:
273
+ if block.type == "text":
274
+ text_parts.append(block.text)
275
+ elif block.type == "tool_use":
276
+ tool_blocks.append(block)
277
+
278
+ if not tool_blocks:
279
+ # Final response — no more tool calls
280
+ reply = " ".join(text_parts).strip()
281
+ is_done = "[DONE]" in reply
282
+ reply = reply.replace("[DONE]", "").strip()
283
+ history.append({"role": "assistant", "content": response.content})
284
+ return reply, is_done
285
+
286
+ # Execute tools and continue
287
+ messages.append({"role": "assistant", "content": response.content})
288
+ tool_results = []
289
+ for tc in tool_blocks:
290
+ result = _run_tool(tc.name, tc.input, cfg_loader, store)
291
+ logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
292
+ tool_results.append({
293
+ "type": "tool_result",
294
+ "tool_use_id": tc.id,
295
+ "content": result,
296
+ })
297
+ messages.append({"role": "user", "content": tool_results})
@@ -0,0 +1,228 @@
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)
@@ -18,3 +18,10 @@ WORKSPACE_DIR=./workspace
18
18
 
19
19
  # Claude Code auth — set if using API key, leave blank for OAuth
20
20
  # ANTHROPIC_API_KEY=sk-ant-...
21
+
22
+ # Slack Bot (optional) — Sentinel Boss conversational interface
23
+ # Create a Slack App at api.slack.com, enable Socket Mode, add scopes:
24
+ # app_mentions:read, chat:write, im:history, channels:history, users:read
25
+ # Then install to workspace and paste both tokens here.
26
+ # SLACK_BOT_TOKEN=xoxb-...
27
+ # SLACK_APP_TOKEN=xapp-...