bloby-bot 0.60.0 → 0.61.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 (28) hide show
  1. package/dist-bloby/assets/{bloby-8GjzRxjC.js → bloby-DO7g-v11.js} +4 -4
  2. package/dist-bloby/assets/globals-CF0bs396.css +2 -0
  3. package/dist-bloby/assets/{globals-D-b6XZqk.js → globals-CwR3dDCz.js} +2 -2
  4. package/dist-bloby/assets/{highlighted-body-OFNGDK62-DrKKm93B.js → highlighted-body-OFNGDK62-C2Wmb17B.js} +1 -1
  5. package/dist-bloby/assets/mermaid-GHXKKRXX-CILe07ZG.js +1 -0
  6. package/dist-bloby/assets/{onboard-DJNuzfZA.js → onboard-DcGLkITd.js} +1 -1
  7. package/dist-bloby/bloby.html +3 -3
  8. package/dist-bloby/onboard.html +3 -3
  9. package/package.json +4 -3
  10. package/shared/config.ts +25 -0
  11. package/supervisor/channels/manager.ts +112 -12
  12. package/supervisor/channels/telegram.ts +361 -0
  13. package/supervisor/channels/types.ts +5 -1
  14. package/supervisor/channels/whatsapp.ts +4 -5
  15. package/supervisor/chat/OnboardWizard.tsx +163 -110
  16. package/supervisor/harnesses/claude.ts +7 -0
  17. package/supervisor/harnesses/pi/index.ts +1 -1
  18. package/supervisor/harnesses/pi/tools/path-safety.ts +8 -1
  19. package/supervisor/index.ts +334 -7
  20. package/supervisor/workspace-guard.js +3 -3
  21. package/worker/prompts/bloby-system-prompt-codex.txt +2 -2
  22. package/worker/prompts/bloby-system-prompt-pi.txt +2 -2
  23. package/worker/prompts/bloby-system-prompt.txt +2 -2
  24. package/workspace/skills/telegram/.claude-plugin/plugin.json +6 -0
  25. package/workspace/skills/telegram/SKILL.md +230 -0
  26. package/workspace/skills/telegram/skill.json +15 -0
  27. package/dist-bloby/assets/globals-eJ7lScsq.css +0 -2
  28. package/dist-bloby/assets/mermaid-GHXKKRXX-CxqocSKs.js +0 -1
@@ -551,6 +551,14 @@ export async function startSupervisor() {
551
551
  'POST /api/channels/whatsapp/react',
552
552
  'POST /api/channels/send',
553
553
  'POST /api/channels/alexa/handle',
554
+ 'POST /api/channels/telegram/connect',
555
+ 'GET /api/channels/telegram/poll',
556
+ 'GET /api/channels/telegram/pair-page',
557
+ 'GET /api/channels/telegram/status',
558
+ 'POST /api/channels/telegram/configure',
559
+ 'POST /api/channels/telegram/disconnect',
560
+ 'POST /api/channels/telegram/reconnect',
561
+ 'POST /api/channels/telegram/logout',
554
562
  ];
555
563
  // Method-specific public PREFIXES — onboarding namespaces with sub-paths / params that carry no
556
564
  // private chat data: provider OAuth setup/status (all of /api/auth/*), and handle availability
@@ -677,6 +685,11 @@ export async function startSupervisor() {
677
685
  'POST /api/channels/whatsapp/logout',
678
686
  'POST /api/channels/whatsapp/pairing-code',
679
687
  'POST /api/channels/send',
688
+ // Telegram mutation endpoints that seize/alter the agent — agent-driven over loopback only.
689
+ 'POST /api/channels/telegram/configure',
690
+ 'POST /api/channels/telegram/disconnect',
691
+ 'POST /api/channels/telegram/reconnect',
692
+ 'POST /api/channels/telegram/logout',
680
693
  ]);
681
694
  if (WA_MUTATION_ROUTES.has(`${req.method} ${channelPath}`)) {
682
695
  const remoteIp = req.socket.remoteAddress || '';
@@ -1348,6 +1361,306 @@ mint();
1348
1361
  return;
1349
1362
  }
1350
1363
 
1364
+ // ── Telegram (relay provisions a per-user bot via "Managed Bots"; the Bloby then
1365
+ // long-polls Telegram DIRECTLY — the relay is only in the pairing path) ──
1366
+
1367
+ // POST /api/channels/telegram/connect — ask the relay to start provisioning a child bot.
1368
+ // Returns a t.me/newbot deep link the user taps + a sessionId to poll. No token yet.
1369
+ if (req.method === 'POST' && channelPath === '/api/channels/telegram/connect') {
1370
+ (async () => {
1371
+ try {
1372
+ const cfg = loadConfig();
1373
+ if (!cfg.relay?.token) {
1374
+ res.writeHead(400);
1375
+ res.end(JSON.stringify({ ok: false, error: 'no-relay-token' }));
1376
+ return;
1377
+ }
1378
+ const resp = await fetch('https://api.bloby.bot/api/telegram/provision', {
1379
+ method: 'POST',
1380
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.relay.token}` },
1381
+ body: JSON.stringify({ name: cfg.username || 'Bloby' }),
1382
+ });
1383
+ const data = await resp.json() as { sessionId?: string; deepLink?: string; botUsername?: string; expiresAt?: string; error?: string };
1384
+ if (!resp.ok || !data.sessionId || !data.deepLink) {
1385
+ res.writeHead(resp.status || 500);
1386
+ res.end(JSON.stringify({ ok: false, error: data.error || 'provision-failed' }));
1387
+ return;
1388
+ }
1389
+ res.writeHead(200);
1390
+ res.end(JSON.stringify({ ok: true, sessionId: data.sessionId, deepLink: data.deepLink, botUsername: data.botUsername, expiresAt: data.expiresAt }));
1391
+ } catch (err: any) {
1392
+ log.warn(`[telegram/connect] ${err.message}`);
1393
+ res.writeHead(500);
1394
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1395
+ }
1396
+ })();
1397
+ return;
1398
+ }
1399
+
1400
+ // GET /api/channels/telegram/poll?sessionId=… — poll the relay for the provisioned token.
1401
+ // When ready, persist the child token locally and start the long-poll provider.
1402
+ if (req.method === 'GET' && channelPath === '/api/channels/telegram/poll') {
1403
+ (async () => {
1404
+ try {
1405
+ const sessionId = new URL(req.url || '', 'http://localhost').searchParams.get('sessionId') || '';
1406
+ if (!sessionId) {
1407
+ res.writeHead(400);
1408
+ res.end(JSON.stringify({ ok: false, error: 'missing-sessionId' }));
1409
+ return;
1410
+ }
1411
+ const cfg = loadConfig();
1412
+ if (!cfg.relay?.token) {
1413
+ res.writeHead(400);
1414
+ res.end(JSON.stringify({ ok: false, error: 'no-relay-token' }));
1415
+ return;
1416
+ }
1417
+ const resp = await fetch(`https://api.bloby.bot/api/telegram/provision/${encodeURIComponent(sessionId)}`, {
1418
+ headers: { Authorization: `Bearer ${cfg.relay.token}` },
1419
+ });
1420
+ const data = await resp.json() as { ready?: boolean; token?: string; botUsername?: string; ownerUserId?: string | number; error?: string };
1421
+ if (!resp.ok) {
1422
+ res.writeHead(resp.status);
1423
+ res.end(JSON.stringify({ ok: false, error: data.error || 'poll-failed' }));
1424
+ return;
1425
+ }
1426
+ if (!data.ready || !data.token) {
1427
+ res.writeHead(200);
1428
+ res.end(JSON.stringify({ ok: true, ready: false }));
1429
+ return;
1430
+ }
1431
+
1432
+ // Provisioned — persist the child bot token and bring the channel up.
1433
+ const fresh = loadConfig();
1434
+ if (!fresh.channels) fresh.channels = {};
1435
+ fresh.channels.telegram = {
1436
+ ...(fresh.channels.telegram || {}),
1437
+ enabled: true,
1438
+ mode: fresh.channels.telegram?.mode || 'channel',
1439
+ botToken: data.token,
1440
+ botUsername: data.botUsername || fresh.channels.telegram?.botUsername,
1441
+ ownerUserId: data.ownerUserId != null ? String(data.ownerUserId) : fresh.channels.telegram?.ownerUserId,
1442
+ };
1443
+ saveConfig(fresh);
1444
+ // Re-pairing replaces the token — tear down any existing provider so init() reconnects
1445
+ // with the NEW token (init() is a no-op when a provider is already in the map).
1446
+ await channelManager.disconnectChannel('telegram').catch(() => {});
1447
+ await channelManager.init().catch(() => {});
1448
+
1449
+ res.writeHead(200);
1450
+ res.end(JSON.stringify({ ok: true, ready: true, botUsername: data.botUsername }));
1451
+ } catch (err: any) {
1452
+ log.warn(`[telegram/poll] ${err.message}`);
1453
+ res.writeHead(500);
1454
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1455
+ }
1456
+ })();
1457
+ return;
1458
+ }
1459
+
1460
+ // POST /api/channels/telegram/configure — set mode + admins + skill (loopback-only).
1461
+ if (req.method === 'POST' && channelPath === '/api/channels/telegram/configure') {
1462
+ let body = '';
1463
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1464
+ req.on('end', () => {
1465
+ try {
1466
+ const data = JSON.parse(body || '{}');
1467
+ const cfg = loadConfig();
1468
+ if (!cfg.channels) cfg.channels = {};
1469
+ if (!cfg.channels.telegram) cfg.channels.telegram = { enabled: false, mode: 'channel' };
1470
+ if (data.mode) cfg.channels.telegram.mode = data.mode;
1471
+ if (data.admins !== undefined) cfg.channels.telegram.admins = data.admins;
1472
+ if (data.skill !== undefined) cfg.channels.telegram.skill = data.skill;
1473
+ if (data.allowGroups !== undefined) cfg.channels.telegram.allowGroups = !!data.allowGroups;
1474
+ if (data.allowOthersToTrigger !== undefined) cfg.channels.telegram.allowOthersToTrigger = !!data.allowOthersToTrigger;
1475
+ saveConfig(cfg);
1476
+ res.writeHead(200);
1477
+ res.end(JSON.stringify({ ok: true, config: { ...cfg.channels.telegram, botToken: cfg.channels.telegram.botToken ? '***' : undefined } }));
1478
+ } catch (err: any) {
1479
+ res.writeHead(400);
1480
+ res.end(JSON.stringify({ error: err.message }));
1481
+ }
1482
+ });
1483
+ return;
1484
+ }
1485
+
1486
+ // POST /api/channels/telegram/disconnect — stop polling, KEEP the token (loopback-only).
1487
+ if (req.method === 'POST' && channelPath === '/api/channels/telegram/disconnect') {
1488
+ (async () => {
1489
+ try {
1490
+ await channelManager.disconnectChannel('telegram');
1491
+ res.writeHead(200);
1492
+ res.end(JSON.stringify({ ok: true }));
1493
+ } catch (err: any) {
1494
+ res.writeHead(500);
1495
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1496
+ }
1497
+ })();
1498
+ return;
1499
+ }
1500
+
1501
+ // POST /api/channels/telegram/logout — stop polling + forget the token (loopback-only).
1502
+ if (req.method === 'POST' && channelPath === '/api/channels/telegram/logout') {
1503
+ (async () => {
1504
+ try {
1505
+ await channelManager.disconnectChannel('telegram');
1506
+ const cfg = loadConfig();
1507
+ if (cfg.channels?.telegram) {
1508
+ delete cfg.channels.telegram.botToken;
1509
+ cfg.channels.telegram.enabled = false;
1510
+ saveConfig(cfg);
1511
+ }
1512
+ res.writeHead(200);
1513
+ res.end(JSON.stringify({ ok: true }));
1514
+ } catch (err: any) {
1515
+ res.writeHead(500);
1516
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1517
+ }
1518
+ })();
1519
+ return;
1520
+ }
1521
+
1522
+ // POST /api/channels/telegram/reconnect — resume polling an already-provisioned bot
1523
+ // after a disconnect, without re-pairing (loopback-only).
1524
+ if (req.method === 'POST' && channelPath === '/api/channels/telegram/reconnect') {
1525
+ (async () => {
1526
+ try {
1527
+ const cfg = loadConfig();
1528
+ if (!cfg.channels?.telegram?.botToken) {
1529
+ res.writeHead(400);
1530
+ res.end(JSON.stringify({ ok: false, error: 'no-bot-token' }));
1531
+ return;
1532
+ }
1533
+ if (cfg.channels.telegram.enabled !== true) {
1534
+ cfg.channels.telegram.enabled = true;
1535
+ saveConfig(cfg);
1536
+ }
1537
+ await channelManager.init().catch(() => {});
1538
+ res.writeHead(200);
1539
+ res.end(JSON.stringify({ ok: true }));
1540
+ } catch (err: any) {
1541
+ res.writeHead(500);
1542
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1543
+ }
1544
+ })();
1545
+ return;
1546
+ }
1547
+
1548
+ // GET /api/channels/telegram/status — current telegram channel status
1549
+ if (req.method === 'GET' && channelPath === '/api/channels/telegram/status') {
1550
+ const status = channelManager.getStatus('telegram');
1551
+ res.writeHead(200);
1552
+ res.end(JSON.stringify(status || { channel: 'telegram', connected: false }));
1553
+ return;
1554
+ }
1555
+
1556
+ // GET /api/channels/telegram/pair-page — inline page: tap to create the bot, then auto-detects link.
1557
+ if (req.method === 'GET' && channelPath === '/api/channels/telegram/pair-page') {
1558
+ res.setHeader('Content-Type', 'text/html');
1559
+ const tgStatus = channelManager.getStatus('telegram');
1560
+ const alreadyLinked = !!(tgStatus?.info as any)?.linked;
1561
+ const linkedUsername = (tgStatus?.info as any)?.botUsername || '';
1562
+ const confettiHTML = Array.from({ length: 30 }, (_, i) => {
1563
+ const colors = ['#0166FF', '#009AFE', '#4AEEFF', '#4ade80', '#facc15', '#818cf8'];
1564
+ const color = colors[Math.floor(Math.random() * colors.length)];
1565
+ const left = Math.random() * 100;
1566
+ const delay = i * 0.04;
1567
+ const drift = (Math.random() - 0.5) * 120;
1568
+ const duration = 1.8 + Math.random() * 0.8;
1569
+ return `<div class="confetti-dot" style="left:${left}%;background:${color};animation-delay:${delay}s;animation-duration:${duration}s;--drift:${drift}px"></div>`;
1570
+ }).join('');
1571
+ res.writeHead(200);
1572
+ res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect Telegram</title>
1573
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1574
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1575
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
1576
+ <style>
1577
+ *{margin:0;padding:0;box-sizing:border-box}
1578
+ body{background:#212121;color:#f5f5f5;font-family:'Inter',system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100dvh;margin:0;overflow-x:hidden}
1579
+ .container{display:flex;flex-direction:column;align-items:center;max-width:380px;width:100%;padding:20px}
1580
+ .card{background:#2a2a2a;border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:28px 24px;width:100%;box-shadow:0 0 0 1px rgba(0,105,254,0.1),0 0 20px -5px rgba(0,105,254,0.15);animation:fade-up .5s ease-out both;text-align:center}
1581
+ .header{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:18px}
1582
+ .badge{display:inline-flex;align-items:center;gap:6px;background:#1a1a1a;border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:4px 10px;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:0.6px}
1583
+ .badge::before{content:'';width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#0166FF,#009AFE);box-shadow:0 0 8px rgba(74,238,255,0.5)}
1584
+ .title{font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:700;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-top:6px}
1585
+ .sub{font-size:13px;color:#999;line-height:1.6;margin-top:6px}
1586
+ .steps{text-align:left;margin:18px 0 6px;animation:fade-up .5s ease-out .2s both}
1587
+ .step{display:flex;gap:12px;font-size:13px;color:#bbb;line-height:1.5;padding:6px 0}
1588
+ .step-num{flex-shrink:0;width:22px;height:22px;border-radius:50%;background:#1a1a1a;border:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#888}
1589
+ .step b{color:#f5f5f5;font-weight:600}
1590
+ .btn{display:inline-flex;align-items:center;justify-content:center;width:100%;border:none;border-radius:10px;padding:13px 16px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;text-decoration:none;margin-top:8px}
1591
+ .btn-primary{background:linear-gradient(135deg,#0166FF,#009AFE);color:#fff}
1592
+ .btn-primary:hover{opacity:.92}
1593
+ .btn-disabled{opacity:.5;pointer-events:none}
1594
+ .status{font-size:12px;color:#666;margin-top:14px;display:inline-flex;align-items:center;gap:6px;min-height:1.2em}
1595
+ .status .dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 1.6s ease-in-out infinite;opacity:.6}
1596
+ .err{color:#FB4072;font-size:13px;margin-top:12px;min-height:1.2em}
1597
+ .confetti-wrap{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden;z-index:0}
1598
+ .confetti-dot{position:absolute;width:8px;height:8px;border-radius:50%;top:-10px;animation:confetti-fall 2s ease-out forwards}
1599
+ @keyframes confetti-fall{0%{opacity:1;transform:translateY(0) translateX(0) rotate(0) scale(1)}100%{opacity:0;transform:translateY(100vh) translateX(var(--drift)) rotate(360deg) scale(.5)}}
1600
+ .video-wrap{margin-bottom:8px;animation:pop-in .5s cubic-bezier(.34,1.56,.64,1) forwards}
1601
+ .video-wrap video{width:200px;object-fit:contain;pointer-events:none}
1602
+ @keyframes pop-in{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
1603
+ .text-wrap{text-align:center;animation:fade-up .5s ease-out .3s both}
1604
+ .success-sub{font-size:14px;color:#999;line-height:1.5;margin-top:6px}
1605
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
1606
+ @keyframes fade-up{0%{opacity:0;transform:translateY(10px)}100%{opacity:1;transform:translateY(0)}}
1607
+ </style></head><body>
1608
+ <div class="container" id="root">
1609
+ ${alreadyLinked
1610
+ ? `<div class="confetti-wrap">${confettiHTML}</div>
1611
+ <div class="video-wrap"><video autoplay muted playsinline><source src="/bloby_happy_reappearing.mov" type='video/mp4; codecs="hvc1"'><source src="/bloby_happy_reappearing.webm" type="video/webm"></video></div>
1612
+ <div class="text-wrap">
1613
+ <div class="title">Connected!</div>
1614
+ <p class="success-sub">Telegram is linked${linkedUsername ? ` to <b style="color:#f5f5f5">@${linkedUsername}</b>` : ''}. Open Telegram and message your bot to start chatting.</p>
1615
+ <p class="success-sub" style="margin-top:14px;font-size:12px;color:#666">You can close this page.</p>
1616
+ </div>`
1617
+ : `<div class="card" id="pendingCard">
1618
+ <div class="header">
1619
+ <span class="badge">Telegram</span>
1620
+ <div class="title">Connect Telegram</div>
1621
+ <p class="sub">Create your own private Telegram bot in two taps — no token, no copy-paste.</p>
1622
+ </div>
1623
+ <div class="steps">
1624
+ <div class="step"><div class="step-num">1</div><div>Tap <b>Create my bot</b> below — Telegram opens with a confirm screen</div></div>
1625
+ <div class="step"><div class="step-num">2</div><div>Tap <b>Create Bot</b> inside Telegram to confirm</div></div>
1626
+ <div class="step"><div class="step-num">3</div><div>Come back here — it links automatically</div></div>
1627
+ </div>
1628
+ <a class="btn btn-primary btn-disabled" id="cta" href="#" target="_blank" rel="noopener">Preparing…</a>
1629
+ <div class="status" id="st"><span class="dot"></span><span id="stText">Getting your link ready…</span></div>
1630
+ <p class="err" id="err"></p>
1631
+ </div>`}
1632
+ </div>
1633
+ <script>
1634
+ ${alreadyLinked ? `` : `
1635
+ let sessionId=null,poller=null;
1636
+ async function start(){
1637
+ try{
1638
+ const r=await fetch('/api/channels/telegram/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
1639
+ const d=await r.json();
1640
+ if(!d.ok){document.getElementById('err').textContent=d.error||'Could not start. Try again.';return}
1641
+ sessionId=d.sessionId;
1642
+ const cta=document.getElementById('cta');
1643
+ cta.href=d.deepLink;cta.classList.remove('btn-disabled');cta.textContent='Create my bot';
1644
+ document.getElementById('stText').textContent='Waiting for you to confirm in Telegram…';
1645
+ if(poller)clearInterval(poller);
1646
+ poller=setInterval(poll,2500);
1647
+ }catch(e){document.getElementById('err').textContent=e.message}
1648
+ }
1649
+ async function poll(){
1650
+ if(!sessionId)return;
1651
+ try{
1652
+ const r=await fetch('/api/channels/telegram/poll?sessionId='+encodeURIComponent(sessionId));
1653
+ const d=await r.json();
1654
+ if(d.ok&&d.ready){clearInterval(poller);location.reload()}
1655
+ }catch(e){/* keep polling */}
1656
+ }
1657
+ start();
1658
+ `}
1659
+ </script>
1660
+ </body></html>`);
1661
+ return;
1662
+ }
1663
+
1351
1664
  // Fallback for unknown channel routes
1352
1665
  res.writeHead(404);
1353
1666
  res.end(JSON.stringify({ error: 'Not found' }));
@@ -1447,8 +1760,8 @@ mint();
1447
1760
  req.on('end', () => {
1448
1761
  if (tooLarge) return;
1449
1762
  try {
1450
- const { vars } = JSON.parse(body) as { vars: Record<string, string> };
1451
- if (!vars || typeof vars !== 'object') {
1763
+ const { vars, remove } = JSON.parse(body) as { vars?: Record<string, string>; remove?: string[] };
1764
+ if ((!vars || typeof vars !== 'object') && !Array.isArray(remove)) {
1452
1765
  res.writeHead(400, { 'Content-Type': 'application/json' });
1453
1766
  res.end(JSON.stringify({ error: 'Missing vars object' }));
1454
1767
  return;
@@ -1460,7 +1773,20 @@ mint();
1460
1773
  lines = fs.readFileSync(envPath, 'utf-8').split('\n');
1461
1774
  }
1462
1775
 
1463
- for (const [rawKey, rawValue] of Object.entries(vars)) {
1776
+ // Delete requested keys first (so removing then re-adding the same key in one request
1777
+ // behaves predictably). A key line is `KEY=...` / `KEY =...`.
1778
+ if (Array.isArray(remove)) {
1779
+ for (const rawKey of remove) {
1780
+ const key = String(rawKey).trim();
1781
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
1782
+ lines = lines.filter((l) => {
1783
+ const t = l.trim();
1784
+ return !(t.startsWith(`${key}=`) || t.startsWith(`${key} =`));
1785
+ });
1786
+ }
1787
+ }
1788
+
1789
+ for (const [rawKey, rawValue] of Object.entries(vars || {})) {
1464
1790
  const key = rawKey.trim();
1465
1791
  // Validate the key as a real env var name; reject anything that could inject extra
1466
1792
  // lines or break .env parsing.
@@ -1552,7 +1878,6 @@ mint();
1552
1878
  id: c.id,
1553
1879
  schedule: c.schedule,
1554
1880
  task: typeof c.task === 'string' ? c.task : '',
1555
- enabled: !!c.enabled, // mirror the scheduler's `!cron.enabled` skip (undefined → disabled)
1556
1881
  oneShot: !!c.oneShot,
1557
1882
  paused: c.paused === true,
1558
1883
  hasTaskFile: CRON_ID_RE.test(c.id || '') && fs.existsSync(path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`)),
@@ -2166,7 +2491,9 @@ mint();
2166
2491
  (!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
2167
2492
  );
2168
2493
  if (wantsHtml && isBackendDead()) {
2169
- res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
2494
+ // X-Bloby-Origin marks this as the agent's OWN branded page so the relay passes it through
2495
+ // (and never mistakes it for a Cloudflare tunnel error to be replaced).
2496
+ res.writeHead(503, { 'Content-Type': 'text/html', 'X-Bloby-Origin': 'supervisor', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
2170
2497
  res.end(backendDownPage(readBackendLogTail(100)));
2171
2498
  return;
2172
2499
  }
@@ -2174,7 +2501,7 @@ mint();
2174
2501
  // Vite failed to boot (sentinel port) → serve the recovering page directly instead of
2175
2502
  // proxying to a dead port. Chat (/bloby/*) is served earlier, so the lifeline stays up.
2176
2503
  if (vitePorts.dashboard < 0) {
2177
- res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
2504
+ res.writeHead(503, { 'Content-Type': 'text/html', 'X-Bloby-Origin': 'supervisor', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
2178
2505
  res.end(RECOVERING_HTML);
2179
2506
  return;
2180
2507
  }
@@ -2216,7 +2543,7 @@ mint();
2216
2543
  );
2217
2544
  proxy.on('error', (e) => {
2218
2545
  console.error(`[supervisor] Dashboard Vite proxy error: ${req.url}`, e.message);
2219
- res.writeHead(503, { 'Content-Type': 'text/html' });
2546
+ res.writeHead(503, { 'Content-Type': 'text/html', 'X-Bloby-Origin': 'supervisor' });
2220
2547
  res.end(RECOVERING_HTML);
2221
2548
  });
2222
2549
  req.pipe(proxy);
@@ -60,9 +60,9 @@
60
60
  '<source src="/what-happened.webm" type="video/webm"><source src="/what-happened.mp4" type="video/mp4">' +
61
61
  '</video>' +
62
62
  '</div>' +
63
- '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">A screen in your app has an error</h1>' +
64
- '<p style="color:#e4e4e7;font-size:1rem;line-height:1.6;margin:0 0 .4rem">Your latest change didn\'t compile.</p>' +
65
- '<p style="color:#a1a1aa;font-size:.93rem;line-height:1.6;margin:0 0 1.2rem">Ask your agent to fix it the chat is in the bottom corner. Copy the error so it can debug faster.</p>' +
63
+ '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">Workspace error</h1>' +
64
+ '<p style="color:#e4e4e7;font-size:1rem;line-height:1.6;margin:0 0 .4rem">Your latest frontend change didnt compile. If your Bloby is currently building or editing something, this is usually normal while the work is still in progress.</p>' +
65
+ '<p style="color:#a1a1aa;font-size:.93rem;line-height:1.6;margin:0 0 1.2rem">When your agent finishes, this may fix itself. If it doesn’t, ask your Bloby to fix it and copy the error so it can debug faster.</p>' +
66
66
  '<div><button id="__bloby_fe_copy" style="font:inherit;cursor:pointer;border:none;border-radius:10px;padding:.65rem 1.2rem;font-size:.9rem;font-weight:600;background:linear-gradient(135deg,#0166FF,#0069FE);color:#fff">Copy error for your agent</button> ' +
67
67
  '<button id="__bloby_fe_dismiss" style="font:inherit;cursor:pointer;border-radius:10px;padding:.65rem 1.1rem;font-size:.9rem;font-weight:600;border:1px solid #27272a;background:#18181b;color:#e4e4e7">Dismiss</button></div>' +
68
68
  '</div>';
@@ -146,14 +146,14 @@ An array of scheduled tasks:
146
146
 
147
147
  Your human can ask you to:
148
148
  - Add a cron ("every morning at 9, summarize my notes")
149
- - Remove or disable a cron ("stop the daily summary")
149
+ - Remove a cron ("stop the daily summary") — delete its entry from CRONS.json
150
150
  - Change a schedule ("move the summary to 8am")
151
151
  - List active crons ("what's scheduled?")
152
152
  - Set a one-time reminder ("remind me at 3pm to call the dentist")
153
153
 
154
154
  Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat. The `paused` field is user-controlled — leave it alone (see below).
155
155
 
156
- The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself.** When you edit a cron with the Write or Edit tool (changing its schedule, task, or `enabled`), preserve whatever `paused` value is already there. If your human asks you to "resume" or "unpause" a cron in chat, set `"paused": false` (or delete the field). To disable a cron yourself, use `enabled: false` leave `paused` for the user. `enabled` is the agent's switch; `paused` is the human's switch; the scheduler skips a cron when EITHER `enabled` is false OR `paused` is true.
156
+ The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself** preserve whatever `paused` value is already there when you edit a cron. If your human asks you in chat to pause or resume a cron, set `"paused": true` or `"paused": false`. To stop a cron permanently, delete its entry from CRONS.json (and its `tasks/{id}.md` file if it has one).
157
157
 
158
158
  **Timezone: all cron schedules use system local time.** The scheduler evaluates crons against the system clock — no UTC conversion needed. When creating a cron, run `date` to check the current local time and write the schedule accordingly. If your human says "remind me at 3pm", use `0 15 * * *` — that's 3pm in whatever timezone the system is set to.
159
159
 
@@ -146,14 +146,14 @@ An array of scheduled tasks:
146
146
 
147
147
  Your human can ask you to:
148
148
  - Add a cron ("every morning at 9, summarize my notes")
149
- - Remove or disable a cron ("stop the daily summary")
149
+ - Remove a cron ("stop the daily summary") — delete its entry from CRONS.json
150
150
  - Change a schedule ("move the summary to 8am")
151
151
  - List active crons ("what's scheduled?")
152
152
  - Set a one-time reminder ("remind me at 3pm to call the dentist")
153
153
 
154
154
  Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat. The `paused` field is user-controlled — leave it alone (see below).
155
155
 
156
- The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself.** When you edit a cron with the Write or Edit tool (changing its schedule, task, or `enabled`), preserve whatever `paused` value is already there. If your human asks you to "resume" or "unpause" a cron in chat, set `"paused": false` (or delete the field). To disable a cron yourself, use `enabled: false` leave `paused` for the user. `enabled` is the agent's switch; `paused` is the human's switch; the scheduler skips a cron when EITHER `enabled` is false OR `paused` is true.
156
+ The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself** preserve whatever `paused` value is already there when you edit a cron. If your human asks you in chat to pause or resume a cron, set `"paused": true` or `"paused": false`. To stop a cron permanently, delete its entry from CRONS.json (and its `tasks/{id}.md` file if it has one).
157
157
 
158
158
  **Timezone: all cron schedules use system local time.** The scheduler evaluates crons against the system clock — no UTC conversion needed. When creating a cron, run `date` to check the current local time and write the schedule accordingly. If your human says "remind me at 3pm", use `0 15 * * *` — that's 3pm in whatever timezone the system is set to.
159
159
 
@@ -146,14 +146,14 @@ An array of scheduled tasks:
146
146
 
147
147
  Your human can ask you to:
148
148
  - Add a cron ("every morning at 9, summarize my notes")
149
- - Remove or disable a cron ("stop the daily summary")
149
+ - Remove a cron ("stop the daily summary") — delete its entry from CRONS.json
150
150
  - Change a schedule ("move the summary to 8am")
151
151
  - List active crons ("what's scheduled?")
152
152
  - Set a one-time reminder ("remind me at 3pm to call the dentist")
153
153
 
154
154
  Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat. The `paused` field is user-controlled — leave it alone (see below).
155
155
 
156
- The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself.** When you edit a cron with the Write or Edit tool (changing its schedule, task, or `enabled`), preserve whatever `paused` value is already there. If your human asks you to "resume" or "unpause" a cron in chat, set `"paused": false` (or delete the field). To disable a cron yourself, use `enabled: false` leave `paused` for the user. `enabled` is the agent's switch; `paused` is the human's switch; the scheduler skips a cron when EITHER `enabled` is false OR `paused` is true.
156
+ The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself** preserve whatever `paused` value is already there when you edit a cron. If your human asks you in chat to pause or resume a cron, set `"paused": true` or `"paused": false`. To stop a cron permanently, delete its entry from CRONS.json (and its `tasks/{id}.md` file if it has one).
157
157
 
158
158
  **Timezone: all cron schedules use system local time.** The scheduler evaluates crons against the system clock — no UTC conversion needed. When creating a cron, run `date` to check the current local time and write the schedule accordingly. If your human says "remind me at 3pm", use `0 15 * * *` — that's 3pm in whatever timezone the system is set to.
159
159
 
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "telegram",
3
+ "version": "1.0.0",
4
+ "description": "Telegram channel via a relay-provisioned private bot. Two-tap pairing, direct long-poll, voice/photo, channel/business/assistant modes.",
5
+ "skills": "./"
6
+ }