@misterhuydo/sentinel 1.0.55 → 1.0.57

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.
File without changes
package/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-03-22T12:03:28.852Z
1
+ 2026-03-22T14:06:29.625Z
@@ -0,0 +1 @@
1
+ {}
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-22T12:06:59.426Z",
3
- "checkpoint_at": "2026-03-22T12:06:59.427Z",
2
+ "message": "Auto-checkpoint at 2026-03-22T14:06:57.074Z",
3
+ "checkpoint_at": "2026-03-22T14:06:57.075Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync, spawnSync } = require('child_process');
6
+ const prompts = require('prompts');
7
+ const chalk = require('chalk');
8
+ const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
9
+ const ok = msg => console.log(chalk.green(' ✔'), msg);
10
+ const info = msg => console.log(chalk.cyan(' →'), msg);
11
+ const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
12
+ const step = msg => console.log('\n' + chalk.bold.white(msg));
13
+ module.exports = async function init() {
14
+ const defaultWorkspace = path.join(os.homedir(), 'sentinel');
15
+ const existing = readExistingConfig(defaultWorkspace);
16
+ if (Object.keys(existing).length) {
17
+ console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
18
+ }
19
+ const answers = await prompts([
20
+ {
21
+ type: 'text',
22
+ name: 'workspace',
23
+ message: 'Workspace directory (each project lives here as a subdirectory)',
24
+ initial: path.join(os.homedir(), 'sentinel'),
25
+ format: v => v.replace(/^~/, os.homedir()),
26
+ },
27
+ {
28
+ type: 'select',
29
+ name: 'authMode',
30
+ message: 'How will Claude Code authenticate?',
31
+ choices: [
32
+ { title: 'API key (Anthropic API account — recommended for servers)', value: 'apikey' },
33
+ { title: 'Claude Pro / OAuth (will give you a URL to open in any browser)', value: 'oauth' },
34
+ { title: 'Skip (I will configure this later)', value: 'skip' },
35
+ ],
36
+ },
37
+ {
38
+ type: prev => prev === 'apikey' ? 'password' : null,
39
+ name: 'anthropicKey',
40
+ message: 'Anthropic API key (sk-ant-...)',
41
+ validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
42
+ },
43
+ {
44
+ type: 'confirm',
45
+ name: 'example',
46
+ message: 'Create an example project to show how to configure?',
47
+ initial: true,
48
+ },
49
+ {
50
+ type: 'confirm',
51
+ name: 'systemd',
52
+ message: 'Set up systemd service for auto-start on reboot?',
53
+ initial: process.platform === 'linux',
54
+ },
55
+ {
56
+ type: 'text',
57
+ name: 'smtpUser',
58
+ message: 'SMTP sender address',
59
+ initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
60
+ },
61
+ {
62
+ type: prev => prev ? 'password' : null,
63
+ name: 'smtpPassword',
64
+ message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
65
+ },
66
+ {
67
+ type: prev => prev ? 'text' : null,
68
+ name: 'smtpHost',
69
+ message: 'SMTP host',
70
+ initial: existing.SMTP_HOST || 'smtp.gmail.com',
71
+ },
72
+ {
73
+ type: 'confirm',
74
+ name: 'setupSlack',
75
+ message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
76
+ initial: !!(existing.SLACK_BOT_TOKEN),
77
+ },
78
+ {
79
+ type: prev => prev ? 'password' : null,
80
+ name: 'slackBotToken',
81
+ message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
82
+ validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
83
+ },
84
+ {
85
+ type: (_, { setupSlack }) => setupSlack ? 'password' : null,
86
+ name: 'slackAppToken',
87
+ message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
88
+ validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
89
+ },
90
+ ], { onCancel: () => process.exit(0) });
91
+ const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
92
+ const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
93
+ const effectiveSlackBotToken = slackBotToken || existing.SLACK_BOT_TOKEN || '';
94
+ const effectiveSlackAppToken = slackAppToken || existing.SLACK_APP_TOKEN || '';
95
+ const codeDir = path.join(workspace, 'code');
96
+ step('Checking Python…');
97
+ const python = findPython();
98
+ if (!python) {
99
+ console.error(chalk.red(' ✖ python3 not found. Install it first:'));
100
+ console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
101
+ process.exit(1);
102
+ }
103
+ ok(`Python: ${run(python, ['--version']).trim()}`);
104
+ step('Installing Sentinel code…');
105
+ const bundledPython = path.join(__dirname, '..', 'python');
106
+ if (!fs.existsSync(bundledPython)) {
107
+ console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
108
+ process.exit(1);
109
+ }
110
+ fs.ensureDirSync(codeDir);
111
+ fs.copySync(bundledPython, codeDir, { overwrite: true });
112
+ ok(`Sentinel code → ${codeDir}`);
113
+ step('Setting up Python environment…');
114
+ const venv = path.join(codeDir, '.venv');
115
+ if (!fs.existsSync(venv)) {
116
+ info('Creating virtual environment…');
117
+ runLive(python, ['-m', 'venv', venv]);
118
+ }
119
+ const pip = path.join(venv, 'bin', 'pip3');
120
+ const pythonBin = path.join(venv, 'bin', 'python3');
121
+ info('Installing Python packages…');
122
+ runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
123
+ runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
124
+ ok('Python packages installed');
125
+ step('Installing Node tools…');
126
+ installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
127
+ installNpmGlobal('@anthropic-ai/claude-code', 'claude');
128
+ step('Claude Code authentication…');
129
+ if (authMode === 'apikey' && anthropicKey) {
130
+ ok('API key will be written to each project\'s sentinel.properties');
131
+ } else if (authMode === 'oauth') {
132
+ info('OAuth selected — start.sh will prompt for login if not yet authenticated');
133
+ } else {
134
+ info('Skipping auth — start.sh will prompt for login if needed');
135
+ }
136
+ if (effectiveSlackBotToken && effectiveSlackAppToken) {
137
+ step('Slack Bot (Sentinel Boss)…');
138
+ ok('Tokens will be written to workspace sentinel.properties');
139
+ info('Sentinel Boss starts automatically when the project starts');
140
+ } else if (setupSlack) {
141
+ warn('Slack tokens not provided — add them to config/sentinel.properties later');
142
+ }
143
+ step('Creating workspace…');
144
+ fs.ensureDirSync(workspace);
145
+ ok(`Workspace: ${workspace}`);
146
+ if (example) {
147
+ step('Creating example project…');
148
+ const exampleDir = path.join(workspace, 'my-project');
149
+ writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '', { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
150
+ ok(`Example project: ${exampleDir}`);
151
+ }
152
+ step('Generating scripts…');
153
+ generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
154
+ ok(`${workspace}/startAll.sh`);
155
+ ok(`${workspace}/stopAll.sh`);
156
+ if (systemd) {
157
+ step('Setting up systemd…');
158
+ setupSystemd(workspace);
159
+ }
160
+ console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
161
+ console.log(`${chalk.bold('Next steps:')}`);
162
+ if (example) {
163
+ console.log(`
164
+ 1. Configure your first project:
165
+ ${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
166
+ ${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
167
+ ${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
168
+ 2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
169
+ ${chalk.cyan(`${workspace}/startAll.sh`)}
170
+ 3. Stop all projects:
171
+ ${chalk.cyan(`${workspace}/stopAll.sh`)}
172
+ `);
173
+ }
174
+ if (systemd) {
175
+ console.log(` Auto-start is enabled. To manage:
176
+ ${chalk.cyan('sudo systemctl start sentinel')}
177
+ ${chalk.cyan('sudo systemctl status sentinel')}
178
+ ${chalk.cyan('journalctl -u sentinel -f')}
179
+ `);
180
+ }
181
+ console.log(` Add another project anytime:
182
+ ${chalk.cyan('sentinel add <project-name>')}
183
+ `);
184
+ };
185
+ function readExistingConfig(workspace) {
186
+ const result = {};
187
+ _parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
188
+ if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
189
+ try {
190
+ for (const entry of fs.readdirSync(workspace)) {
191
+ const p = path.join(workspace, entry, 'config', 'sentinel.properties');
192
+ const proj = {};
193
+ _parsePropsInto(p, proj);
194
+ if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
195
+ if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
196
+ if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
197
+ }
198
+ } catch (_) {}
199
+ }
200
+ return result;
201
+ }
202
+ function _parsePropsInto(propsPath, result) {
203
+ if (!fs.existsSync(propsPath)) return;
204
+ try {
205
+ const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
206
+ for (const raw of lines) {
207
+ const line = raw.trim();
208
+ if (!line || line.startsWith('#')) continue;
209
+ const idx = line.indexOf('=');
210
+ if (idx === -1) continue;
211
+ result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
212
+ }
213
+ } catch (_) {}
214
+ }
215
+ function findPython() {
216
+ for (const bin of ['python3', 'python']) {
217
+ try {
218
+ execSync(`${bin} --version`, { stdio: 'pipe' });
219
+ return bin;
220
+ } catch (_) {}
221
+ }
222
+ return null;
223
+ }
224
+ function run(bin, args) {
225
+ const r = spawnSync(bin, args, { encoding: 'utf8' });
226
+ return (r.stdout || '') + (r.stderr || '');
227
+ }
228
+ function runLive(bin, args) {
229
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
230
+ if (r.status !== 0) {
231
+ console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
232
+ process.exit(1);
233
+ }
234
+ }
235
+ function installNpmGlobal(pkg, checkBin) {
236
+ try {
237
+ execSync(`${checkBin} --version`, { stdio: 'pipe' });
238
+ ok(`${pkg} already installed`);
239
+ } catch (_) {
240
+ info(`Installing ${pkg}…`);
241
+ runLive('npm', ['install', '-g', pkg]);
242
+ ok(`${pkg} installed`);
243
+ }
244
+ }
245
+ function setupSystemd(workspace) {
246
+ const user = os.userInfo().username;
247
+ const svc = `/etc/systemd/system/sentinel.service`;
248
+ const content = `[Unit]
249
+ Description=Sentinel — Autonomous DevOps Agent
250
+ After=network-online.target
251
+ Wants=network-online.target
252
+ [Service]
253
+ Type=forking
254
+ User=${user}
255
+ WorkingDirectory=${workspace}
256
+ ExecStart=${workspace}/startAll.sh
257
+ ExecStop=${workspace}/stopAll.sh
258
+ Restart=on-failure
259
+ RestartSec=10
260
+ [Install]
261
+ WantedBy=multi-user.target
262
+ `;
263
+ try {
264
+ fs.writeFileSync('/tmp/sentinel.service', content);
265
+ execSync(`sudo mv /tmp/sentinel.service ${svc}`);
266
+ execSync('sudo systemctl daemon-reload');
267
+ execSync('sudo systemctl enable sentinel');
268
+ ok('sentinel.service enabled');
269
+ } catch (e) {
270
+ warn(`Could not write systemd service (need sudo): ${e.message}`);
271
+ warn(`Manually create ${svc} to auto-start on reboot`);
272
+ }
273
+ }
package/lib/init.js CHANGED
@@ -140,6 +140,8 @@ module.exports = async function init() {
140
140
  step('Installing Node tools…');
141
141
  installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
142
142
  installNpmGlobal('@anthropic-ai/claude-code', 'claude');
143
+ info('Hooking Cairn MCP into Claude Code…');
144
+ runLive('cairn', ['install']);
143
145
 
144
146
  // ── Claude Code auth ─────────────────────────────────────────────────────────
145
147
  step('Claude Code authentication…');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.55",
3
+ "version": "1.0.57",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -919,7 +919,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
919
919
  start_sh = project_dir / "start.sh"
920
920
  if stop_sh.exists() and start_sh.exists():
921
921
  def _restart_scripts():
922
- import time; time.sleep(2)
922
+ import time; time.sleep(10)
923
923
  subprocess.Popen(
924
924
  f"bash {stop_sh} && sleep 2 && bash {start_sh}",
925
925
  shell=True,
@@ -928,7 +928,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
928
928
  restart_method = "stop.sh + start.sh"
929
929
  else:
930
930
  # SIGTERM self — systemd (Restart=always) will bring it back up
931
- threading.Timer(2.0, lambda: os.kill(os.getpid(), _sig.SIGTERM)).start()
931
+ # 10s delay gives Claude time to generate + post the reply before we die
932
+ threading.Timer(10.0, lambda: os.kill(os.getpid(), _sig.SIGTERM)).start()
932
933
  restart_method = "SIGTERM → systemd restart"
933
934
 
934
935
  steps.append({"step": "restart", "status": "scheduled", "method": restart_method})
@@ -1032,6 +1033,8 @@ async def _handle_with_cli(
1032
1033
  reply = _ACTION_RE.sub("", output).strip()
1033
1034
  is_done = "[DONE]" in reply
1034
1035
  reply = reply.replace("[DONE]", "").strip()
1036
+ if not reply:
1037
+ reply = "Done."
1035
1038
 
1036
1039
  history.append({"role": "user", "content": message})
1037
1040
  history.append({"role": "assistant", "content": reply})
@@ -1114,6 +1117,8 @@ async def handle_message(
1114
1117
  reply = " ".join(text_parts).strip()
1115
1118
  is_done = "[DONE]" in reply
1116
1119
  reply = reply.replace("[DONE]", "").strip()
1120
+ if not reply:
1121
+ reply = "Done."
1117
1122
  history.append({"role": "assistant", "content": response.content})
1118
1123
  return reply, is_done
1119
1124
 
@@ -324,6 +324,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
324
324
  # Typing indicator
325
325
  await _post(client, channel, "_thinking..._")
326
326
 
327
+ reply = ""
328
+ is_done = True
327
329
  try:
328
330
  reply, is_done = await handle_message(
329
331
  message, session.history, cfg_loader, store,
@@ -331,8 +333,7 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
331
333
  )
332
334
  except Exception as e:
333
335
  logger.exception("Sentinel Boss error: %s", e)
334
- await _post(client, channel, f":warning: Unhandled error: {e}")
335
- is_done = True
336
+ reply = f":warning: Unhandled error: {e}"
336
337
 
337
338
  await _post(client, channel, reply)
338
339
 
@@ -353,6 +354,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
353
354
  # ── Helpers ───────────────────────────────────────────────────────────────────
354
355
 
355
356
  async def _post(client, channel: str, text: str) -> None:
357
+ if not text:
358
+ return
356
359
  try:
357
360
  await client.chat_postMessage(channel=channel, text=text)
358
361
  except Exception as e: