@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.
- package/.cairn/.cairn-project +0 -0
- package/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +1 -0
- package/.cairn/session.json +2 -2
- package/.cairn/views/2a85cc_init.js +273 -0
- package/lib/init.js +2 -0
- package/package.json +1 -1
- package/python/sentinel/sentinel_boss.py +7 -2
- package/python/sentinel/slack_bot.py +5 -2
|
File without changes
|
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-22T14:06:29.625Z
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|