alvin-bot 4.5.1 → 4.7.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/README.md +25 -2
  3. package/bin/cli.js +325 -26
  4. package/dist/handlers/commands.js +505 -63
  5. package/dist/handlers/message.js +209 -14
  6. package/dist/i18n.js +470 -13
  7. package/dist/index.js +45 -5
  8. package/dist/providers/claude-sdk-provider.js +106 -14
  9. package/dist/providers/ollama-provider.js +32 -0
  10. package/dist/providers/openai-compatible.js +10 -1
  11. package/dist/providers/registry.js +112 -17
  12. package/dist/providers/types.js +25 -3
  13. package/dist/services/compaction.js +2 -0
  14. package/dist/services/cron.js +53 -42
  15. package/dist/services/heartbeat.js +41 -7
  16. package/dist/services/language-detect.js +12 -2
  17. package/dist/services/ollama-manager.js +339 -0
  18. package/dist/services/personality.js +20 -14
  19. package/dist/services/session.js +21 -3
  20. package/dist/services/subagent-delivery.js +266 -0
  21. package/dist/services/subagent-stats.js +123 -0
  22. package/dist/services/subagents.js +509 -42
  23. package/dist/services/telegram.js +28 -1
  24. package/dist/services/updater.js +158 -0
  25. package/dist/services/usage-tracker.js +11 -4
  26. package/dist/services/users.js +2 -1
  27. package/docs/HANDBOOK.md +856 -0
  28. package/package.json +7 -2
  29. package/test/claude-sdk-provider.test.ts +69 -0
  30. package/test/i18n.test.ts +108 -0
  31. package/test/registry.test.ts +201 -0
  32. package/test/subagent-delivery.test.ts +273 -0
  33. package/test/subagent-stats.test.ts +119 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +114 -0
  36. package/test/subagents-depth.test.ts +58 -0
  37. package/test/subagents-inheritance.test.ts +67 -0
  38. package/test/subagents-name-resolver.test.ts +122 -0
  39. package/test/subagents-priority-reject.test.ts +88 -0
  40. package/test/subagents-queue.test.ts +127 -0
  41. package/test/subagents-shutdown.test.ts +126 -0
  42. package/test/subagents-toolset.test.ts +51 -0
  43. package/vitest.config.ts +17 -0
package/bin/cli.js CHANGED
@@ -1153,6 +1153,232 @@ async function version() {
1153
1153
  }
1154
1154
  }
1155
1155
 
1156
+ // ── LaunchAgent helpers (macOS only) ────────────────────────────────────────
1157
+
1158
+ /**
1159
+ * Render the launchd plist that runs `node dist/index.js` as a per-user
1160
+ * agent. Inherits the GUI login session so the macOS Keychain is
1161
+ * automatically unlocked — which means Claude Code OAuth tokens (Max
1162
+ * subscription) work without a manual `security unlock-keychain`.
1163
+ */
1164
+ function renderLaunchdPlist({ label, nodePath, entryPoint, cwd, home, logDir }) {
1165
+ // PATH covers both Apple Silicon and Intel Homebrew plus the legacy
1166
+ // user-local claude binary path.
1167
+ const pathValue = `${home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
1168
+ return `<?xml version="1.0" encoding="UTF-8"?>
1169
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1170
+ <plist version="1.0">
1171
+ <dict>
1172
+ <key>Label</key>
1173
+ <string>${label}</string>
1174
+
1175
+ <key>ProgramArguments</key>
1176
+ <array>
1177
+ <string>${nodePath}</string>
1178
+ <string>${entryPoint}</string>
1179
+ </array>
1180
+
1181
+ <key>WorkingDirectory</key>
1182
+ <string>${cwd}</string>
1183
+
1184
+ <key>RunAtLoad</key>
1185
+ <true/>
1186
+
1187
+ <key>KeepAlive</key>
1188
+ <dict>
1189
+ <key>SuccessfulExit</key>
1190
+ <false/>
1191
+ <key>Crashed</key>
1192
+ <true/>
1193
+ </dict>
1194
+
1195
+ <key>ThrottleInterval</key>
1196
+ <integer>10</integer>
1197
+
1198
+ <key>StandardOutPath</key>
1199
+ <string>${logDir}/alvin-bot.out.log</string>
1200
+
1201
+ <key>StandardErrorPath</key>
1202
+ <string>${logDir}/alvin-bot.err.log</string>
1203
+
1204
+ <key>EnvironmentVariables</key>
1205
+ <dict>
1206
+ <key>PATH</key>
1207
+ <string>${pathValue}</string>
1208
+ <key>HOME</key>
1209
+ <string>${home}</string>
1210
+ <key>NODE_ENV</key>
1211
+ <string>production</string>
1212
+ </dict>
1213
+ </dict>
1214
+ </plist>
1215
+ `;
1216
+ }
1217
+
1218
+ /**
1219
+ * Common paths + label used by all three launchd subcommands.
1220
+ */
1221
+ function launchdPaths() {
1222
+ const home = homedir();
1223
+ const label = "com.alvinbot.app";
1224
+ const plistPath = join(home, "Library", "LaunchAgents", `${label}.plist`);
1225
+ const logDir = join(home, ".alvin-bot", "logs");
1226
+ // dist/index.js lives two levels up from bin/cli.js, then dist/
1227
+ const entryPoint = resolve(join(import.meta.dirname, "..", "dist", "index.js"));
1228
+ const cwd = resolve(join(import.meta.dirname, ".."));
1229
+ const nodePath = process.execPath;
1230
+ return { home, label, plistPath, logDir, entryPoint, cwd, nodePath };
1231
+ }
1232
+
1233
+ async function launchdInstall() {
1234
+ if (process.platform !== "darwin") {
1235
+ console.log("❌ alvin-bot launchd is macOS-only.");
1236
+ console.log(" Linux users: create a systemd user unit for dist/index.js.");
1237
+ console.log(" Windows users: use Task Scheduler or NSSM.");
1238
+ process.exit(1);
1239
+ }
1240
+
1241
+ const { home, label, plistPath, logDir, entryPoint, cwd, nodePath } = launchdPaths();
1242
+
1243
+ // Sanity-check that dist/ is built
1244
+ if (!existsSync(entryPoint)) {
1245
+ console.log(`❌ Build not found at ${entryPoint}`);
1246
+ console.log(" Run 'npm run build' first.");
1247
+ process.exit(1);
1248
+ }
1249
+
1250
+ // Ensure the LaunchAgents dir and log dir exist
1251
+ mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true });
1252
+ mkdirSync(logDir, { recursive: true });
1253
+
1254
+ // Render and write the plist
1255
+ const plist = renderLaunchdPlist({ label, nodePath, entryPoint, cwd, home, logDir });
1256
+ writeFileSync(plistPath, plist, { mode: 0o644 });
1257
+ console.log(`📝 Wrote ${plistPath}`);
1258
+
1259
+ // Unload any previous instance (best-effort)
1260
+ try {
1261
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "pipe" });
1262
+ } catch { /* not loaded yet — fine */ }
1263
+
1264
+ // Stop any nohup'd bot that might still be running
1265
+ try {
1266
+ execSync(`pkill -TERM -f 'node.*dist/index.js' || true`, { stdio: "pipe" });
1267
+ } catch { /* nothing to kill */ }
1268
+
1269
+ // Load fresh
1270
+ try {
1271
+ execSync(`launchctl load -w "${plistPath}"`, { stdio: "inherit" });
1272
+ } catch (err) {
1273
+ console.log(`❌ launchctl load failed: ${err.message}`);
1274
+ console.log(" Try manually: launchctl load -w " + plistPath);
1275
+ process.exit(1);
1276
+ }
1277
+
1278
+ console.log("");
1279
+ console.log("✅ alvin-bot is now a launchd user agent.");
1280
+ console.log(` Label: ${label}`);
1281
+ console.log(` Logs: ${logDir}/alvin-bot.out.log`);
1282
+ console.log(` Errors: ${logDir}/alvin-bot.err.log`);
1283
+ console.log("");
1284
+ console.log(" Status: alvin-bot launchd status");
1285
+ console.log(" Stop: alvin-bot launchd uninstall");
1286
+ console.log(" Restart: launchctl kickstart -k gui/$UID/" + label);
1287
+ console.log("");
1288
+ console.log(" Because launchd runs the bot inside your GUI login session,");
1289
+ console.log(" the macOS Keychain is automatically unlocked — Claude Code");
1290
+ console.log(" OAuth tokens (Max subscription) just work, no SSH keychain");
1291
+ console.log(" dance needed anymore.");
1292
+ process.exit(0);
1293
+ }
1294
+
1295
+ async function launchdUninstall() {
1296
+ if (process.platform !== "darwin") {
1297
+ console.log("❌ alvin-bot launchd is macOS-only.");
1298
+ process.exit(1);
1299
+ }
1300
+ const { plistPath, label } = launchdPaths();
1301
+ if (!existsSync(plistPath)) {
1302
+ console.log(`⚠️ No LaunchAgent plist at ${plistPath}`);
1303
+ console.log(" Nothing to uninstall.");
1304
+ process.exit(0);
1305
+ }
1306
+
1307
+ try {
1308
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "inherit" });
1309
+ console.log(`✅ Unloaded ${label}`);
1310
+ } catch (err) {
1311
+ console.log(`⚠️ Unload reported an error (may not have been running): ${err.message}`);
1312
+ }
1313
+
1314
+ try {
1315
+ execSync(`rm -f "${plistPath}"`);
1316
+ console.log(`🗑 Removed ${plistPath}`);
1317
+ } catch (err) {
1318
+ console.log(`⚠️ Could not remove plist: ${err.message}`);
1319
+ }
1320
+
1321
+ console.log("");
1322
+ console.log("✅ alvin-bot is no longer a launchd user agent.");
1323
+ process.exit(0);
1324
+ }
1325
+
1326
+ async function launchdStatus() {
1327
+ if (process.platform !== "darwin") {
1328
+ console.log("❌ alvin-bot launchd is macOS-only.");
1329
+ process.exit(1);
1330
+ }
1331
+ const { plistPath, label, logDir } = launchdPaths();
1332
+
1333
+ console.log(`📋 alvin-bot launchd status`);
1334
+ console.log("");
1335
+ console.log(`Label: ${label}`);
1336
+ console.log(`Plist: ${plistPath}`);
1337
+ console.log(`Plist exists: ${existsSync(plistPath) ? "yes" : "no"}`);
1338
+ console.log("");
1339
+
1340
+ try {
1341
+ const out = execSync(`launchctl list | grep ${label} || true`, { encoding: "utf-8" });
1342
+ if (out.trim()) {
1343
+ // Format: <PID>\t<ExitCode>\t<Label>
1344
+ const parts = out.trim().split(/\s+/);
1345
+ const pid = parts[0];
1346
+ const exitCode = parts[1];
1347
+ const isRunning = pid !== "-" && pid !== "0";
1348
+ console.log(`Running: ${isRunning ? "✅ yes (PID " + pid + ")" : "❌ no (last exit " + exitCode + ")"}`);
1349
+ } else {
1350
+ console.log(`Running: ❌ not loaded`);
1351
+ }
1352
+ } catch {
1353
+ console.log(`Running: ❌ unknown (launchctl list failed)`);
1354
+ }
1355
+
1356
+ console.log("");
1357
+ console.log(`Log dir: ${logDir}`);
1358
+ const outLog = join(logDir, "alvin-bot.out.log");
1359
+ const errLog = join(logDir, "alvin-bot.err.log");
1360
+ if (existsSync(outLog)) {
1361
+ try {
1362
+ const tail = execSync(`tail -n 5 "${outLog}"`, { encoding: "utf-8" });
1363
+ console.log("");
1364
+ console.log("── Last 5 lines of stdout ──");
1365
+ console.log(tail.trimEnd());
1366
+ } catch { /* ignore */ }
1367
+ }
1368
+ if (existsSync(errLog)) {
1369
+ try {
1370
+ const tail = execSync(`tail -n 5 "${errLog}"`, { encoding: "utf-8" });
1371
+ const trimmed = tail.trimEnd();
1372
+ if (trimmed) {
1373
+ console.log("");
1374
+ console.log("── Last 5 lines of stderr ──");
1375
+ console.log(trimmed);
1376
+ }
1377
+ } catch { /* ignore */ }
1378
+ }
1379
+ process.exit(0);
1380
+ }
1381
+
1156
1382
  // ── CLI Router ──────────────────────────────────────────────────────────────
1157
1383
 
1158
1384
  const cmd = process.argv[2];
@@ -1170,40 +1396,93 @@ switch (cmd) {
1170
1396
  const fg = process.argv.includes("--foreground") || process.argv.includes("-f");
1171
1397
  if (fg) {
1172
1398
  import("../dist/index.js");
1173
- } else {
1174
- // Start via PM2 (background, survives terminal close, auto-restart on crash)
1175
- try {
1176
- execSync("pm2 --version", { stdio: "pipe" });
1177
- } catch {
1178
- // PM2 not installed install it
1179
- console.log("Installing PM2 for background operation...");
1399
+ break;
1400
+ }
1401
+
1402
+ // On macOS, if a LaunchAgent plist already exists, we're in "launchd
1403
+ // mode" — don't start pm2 in parallel. Reload the LaunchAgent instead
1404
+ // so a plain `alvin-bot start` still works as "bring the bot up".
1405
+ if (process.platform === "darwin") {
1406
+ const { plistPath, label } = launchdPaths();
1407
+ if (existsSync(plistPath)) {
1408
+ console.log(`🚀 Detected existing LaunchAgent (${label})`);
1409
+ console.log(` Reloading via 'launchctl kickstart -k'...`);
1180
1410
  try {
1181
- execSync("npm install -g pm2", { stdio: "inherit", timeout: 60000 });
1411
+ execSync(`launchctl kickstart -k gui/$(id -u)/${label}`, {
1412
+ stdio: "inherit",
1413
+ shell: "/bin/zsh",
1414
+ });
1182
1415
  } catch {
1183
- console.log("Could not install PM2. Starting in foreground instead.");
1184
- console.log("Tip: Install PM2 manually (npm install -g pm2) to run in background.\n");
1185
- await import("../dist/index.js");
1186
- break;
1416
+ // Maybe unloaded load it fresh
1417
+ try {
1418
+ execSync(`launchctl load -w "${plistPath}"`, { stdio: "inherit" });
1419
+ } catch (err) {
1420
+ console.log(`❌ launchctl load failed: ${err.message}`);
1421
+ process.exit(1);
1422
+ }
1187
1423
  }
1424
+ console.log("\n✅ Bot is running via launchd.");
1425
+ console.log(" Status: alvin-bot launchd status");
1426
+ console.log(" Stop: alvin-bot stop");
1427
+ console.log(" Logs: ~/.alvin-bot/logs/alvin-bot.out.log");
1428
+ process.exit(0);
1188
1429
  }
1189
- const cliPath = resolve(join(import.meta.dirname, "cli.js"));
1430
+ }
1431
+
1432
+ // Fall-through: pm2 path (Linux, Windows, or macOS without LaunchAgent)
1433
+ try {
1434
+ execSync("pm2 --version", { stdio: "pipe" });
1435
+ } catch {
1436
+ console.log("Installing PM2 for background operation...");
1190
1437
  try {
1191
- // Stop existing instance if running
1192
- execSync("pm2 delete alvin-bot", { stdio: "pipe" });
1193
- } catch { /* not running fine */ }
1194
- execSync(`pm2 start "${cliPath}" --name alvin-bot -- start --foreground`, {
1195
- stdio: "inherit",
1196
- timeout: 15000,
1197
- });
1198
- console.log("\n✅ Bot is running in the background.");
1199
- console.log(" Logs: pm2 logs alvin-bot");
1200
- console.log(" Stop: alvin-bot stop");
1201
- console.log(" Restart: alvin-bot start\n");
1202
- process.exit(0);
1438
+ execSync("npm install -g pm2", { stdio: "inherit", timeout: 60000 });
1439
+ } catch {
1440
+ console.log("Could not install PM2. Starting in foreground instead.");
1441
+ console.log("Tip: Install PM2 manually (npm install -g pm2) to run in background.\n");
1442
+ await import("../dist/index.js");
1443
+ break;
1444
+ }
1203
1445
  }
1204
- break;
1446
+ const cliPath = resolve(join(import.meta.dirname, "cli.js"));
1447
+ try {
1448
+ execSync("pm2 delete alvin-bot", { stdio: "pipe" });
1449
+ } catch { /* not running — fine */ }
1450
+ execSync(`pm2 start "${cliPath}" --name alvin-bot -- start --foreground`, {
1451
+ stdio: "inherit",
1452
+ timeout: 15000,
1453
+ });
1454
+ console.log("\n✅ Bot is running in the background via PM2.");
1455
+ console.log(" Logs: pm2 logs alvin-bot");
1456
+ console.log(" Stop: alvin-bot stop");
1457
+ console.log(" Restart: alvin-bot start");
1458
+ if (process.platform === "darwin") {
1459
+ console.log("");
1460
+ console.log(" 💡 Tip: on macOS with Claude Code, switch to launchd for");
1461
+ console.log(" automatic Keychain access: alvin-bot launchd install");
1462
+ }
1463
+ console.log("");
1464
+ process.exit(0);
1205
1465
  }
1206
1466
  case "stop": {
1467
+ // On macOS with a LaunchAgent, stopping means unloading the LaunchAgent,
1468
+ // not asking pm2 to stop a process it never managed.
1469
+ if (process.platform === "darwin") {
1470
+ const { plistPath, label } = launchdPaths();
1471
+ if (existsSync(plistPath)) {
1472
+ console.log(`⏹ Stopping LaunchAgent (${label})...`);
1473
+ try {
1474
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "inherit" });
1475
+ console.log("✅ LaunchAgent stopped.");
1476
+ console.log(" (The plist is still installed. To remove it: alvin-bot launchd uninstall)");
1477
+ } catch (err) {
1478
+ console.log(`❌ launchctl unload failed: ${err.message}`);
1479
+ process.exit(1);
1480
+ }
1481
+ process.exit(0);
1482
+ }
1483
+ }
1484
+
1485
+ // Fall-through: pm2 path
1207
1486
  try {
1208
1487
  execSync("pm2 stop alvin-bot", { stdio: "inherit", timeout: 10000 });
1209
1488
  } catch {
@@ -1211,6 +1490,25 @@ switch (cmd) {
1211
1490
  }
1212
1491
  process.exit(0);
1213
1492
  }
1493
+ case "launchd": {
1494
+ const sub = process.argv[3];
1495
+ if (sub === "install") {
1496
+ await launchdInstall();
1497
+ } else if (sub === "uninstall") {
1498
+ await launchdUninstall();
1499
+ } else if (sub === "status") {
1500
+ await launchdStatus();
1501
+ } else {
1502
+ console.log("Usage: alvin-bot launchd <install|uninstall|status>");
1503
+ console.log("");
1504
+ console.log(" install — Install as a macOS launchd user agent.");
1505
+ console.log(" Runs on login, keychain auto-unlocked.");
1506
+ console.log(" uninstall — Unload and remove the LaunchAgent plist.");
1507
+ console.log(" status — Show current launchd state + recent logs.");
1508
+ process.exit(1);
1509
+ }
1510
+ break;
1511
+ }
1214
1512
  case "tui":
1215
1513
  case "chat":
1216
1514
  import("../dist/tui/index.js").then(m => m.startTUI()).catch(console.error);
@@ -1254,6 +1552,7 @@ ${t("cli.commands")}
1254
1552
  start ${t("cli.startDesc")} (background via PM2)
1255
1553
  start -f Start in foreground (for debugging)
1256
1554
  stop Stop the bot
1555
+ launchd macOS only: install/uninstall/status as launchd user agent
1257
1556
  version ${t("cli.versionDesc")}
1258
1557
 
1259
1558
  ${t("cli.example")}