bloby-bot 0.57.0 → 0.59.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.
@@ -23,7 +23,7 @@ import {
23
23
  } from './bloby-agent.js';
24
24
  import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
25
25
  import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
26
- import { startScheduler, stopScheduler } from './scheduler.js';
26
+ import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRunISO, describeCron } from './scheduler.js';
27
27
  import { execSync, spawn as cpSpawn } from 'child_process';
28
28
  import crypto from 'crypto';
29
29
  import { ChannelManager } from './channels/manager.js';
@@ -120,7 +120,7 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
120
120
  // cached shell that masks a broken (or just-fixed) frontend and produces the confusing
121
121
  // "normal refresh is broken but hard refresh works" split. Cache is a pure offline fallback.
122
122
 
123
- var CACHE = 'bloby-v21';
123
+ var CACHE = 'bloby-v23';
124
124
  var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
125
125
 
126
126
  // Precache the HTML shell on install so the cache is never empty.
@@ -1503,6 +1503,152 @@ mint();
1503
1503
  return;
1504
1504
  }
1505
1505
 
1506
+ // ── Pulse & Cron schedule (Settings → Pulse & Crons) ──
1507
+ // Reads/writes workspace PULSE.json + CRONS.json (+ tasks/{id}.md). Same privileged gate as
1508
+ // /api/env — portal token when a password is set, x-internal for internal supervisor calls.
1509
+ // These routes are intercepted before the /api worker gate, so the gate is load-bearing
1510
+ // (CRONS.json + task files hold the agent's instructions). `id` is validated against a slug
1511
+ // regex before any path.join to block path traversal (e.g. id=../../.env).
1512
+ if (req.url === '/api/schedule' || req.url?.startsWith('/api/schedule?') ||
1513
+ req.url === '/api/pulse' || req.url === '/api/crons/pause' ||
1514
+ req.url === '/api/crons/delete' || req.url?.startsWith('/api/crons/task')) {
1515
+ const scheduleAuthed = async (): Promise<boolean> => {
1516
+ if (req.headers['x-internal'] === internalSecret) return true;
1517
+ if (!(await isAuthRequired())) return true;
1518
+ const authHeader = req.headers['authorization'];
1519
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
1520
+ return !!token && await validateToken(token);
1521
+ };
1522
+ if (!(await scheduleAuthed())) {
1523
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1524
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1525
+ return;
1526
+ }
1527
+
1528
+ const CRON_ID_RE = /^[A-Za-z0-9_-]+$/;
1529
+ const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
1530
+ const PULSE_PATH = path.join(WORKSPACE_DIR, 'PULSE.json');
1531
+ const CRONS_PATH = path.join(WORKSPACE_DIR, 'CRONS.json');
1532
+ const sendJson = (code: number, obj: any) => {
1533
+ res.writeHead(code, { 'Content-Type': 'application/json' });
1534
+ res.end(JSON.stringify(obj));
1535
+ };
1536
+ const readBody = () => new Promise<string>((resolve, reject) => {
1537
+ let body = ''; let bytes = 0;
1538
+ req.on('data', (chunk: Buffer) => {
1539
+ bytes += chunk.length;
1540
+ if (bytes > 200_000) { req.destroy(); reject(new Error('Body too large')); return; }
1541
+ body += chunk.toString();
1542
+ });
1543
+ req.on('end', () => resolve(body));
1544
+ req.on('error', reject);
1545
+ });
1546
+
1547
+ // GET /api/schedule — pulse config + crons (humanized schedule, next run, hasTaskFile).
1548
+ if (req.method === 'GET' && (req.url === '/api/schedule' || req.url.startsWith('/api/schedule?'))) {
1549
+ try {
1550
+ const pulse = readPulseConfig();
1551
+ const crons = readCronsConfig().map((c) => ({
1552
+ id: c.id,
1553
+ schedule: c.schedule,
1554
+ task: typeof c.task === 'string' ? c.task : '',
1555
+ enabled: !!c.enabled, // mirror the scheduler's `!cron.enabled` skip (undefined → disabled)
1556
+ oneShot: !!c.oneShot,
1557
+ paused: c.paused === true,
1558
+ hasTaskFile: CRON_ID_RE.test(c.id || '') && fs.existsSync(path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`)),
1559
+ nextRun: nextRunISO(c.schedule),
1560
+ description: describeCron(c.schedule),
1561
+ }));
1562
+ sendJson(200, { pulse, crons });
1563
+ } catch (err: any) {
1564
+ sendJson(500, { error: err.message });
1565
+ }
1566
+ return;
1567
+ }
1568
+
1569
+ // POST /api/pulse — write PULSE.json (validated). Does NOT restart the backend; the
1570
+ // scheduler re-reads PULSE.json on its next 60s tick.
1571
+ if (req.method === 'POST' && req.url === '/api/pulse') {
1572
+ try {
1573
+ const { enabled, intervalMinutes, quietHours } = JSON.parse(await readBody());
1574
+ const mins = Math.floor(Number(intervalMinutes));
1575
+ // Reject NaN/out-of-range — a non-positive interval makes the pulse fire every tick.
1576
+ if (!Number.isFinite(mins) || mins < 1 || mins > 1440) {
1577
+ sendJson(400, { error: 'intervalMinutes must be a whole number between 1 and 1440' });
1578
+ return;
1579
+ }
1580
+ // Reject malformed quiet hours — isInQuietHours can't validate, so a bad value would
1581
+ // silently disable quiet hours entirely.
1582
+ if (!quietHours || typeof quietHours !== 'object' || !TIME_RE.test(quietHours.start) || !TIME_RE.test(quietHours.end)) {
1583
+ sendJson(400, { error: 'quietHours.start and .end must be HH:MM (00:00–23:59)' });
1584
+ return;
1585
+ }
1586
+ const config = { enabled: !!enabled, intervalMinutes: mins, quietHours: { start: quietHours.start, end: quietHours.end } };
1587
+ fs.writeFileSync(PULSE_PATH, JSON.stringify(config, null, 2) + '\n', 'utf-8');
1588
+ sendJson(200, { ok: true });
1589
+ } catch (err: any) {
1590
+ sendJson(500, { error: err.message });
1591
+ }
1592
+ return;
1593
+ }
1594
+
1595
+ // POST /api/crons/pause — read-modify-write the `paused` flag of one cron, preserving
1596
+ // every other field (never reconstruct the entry — that would clobber enabled/oneShot/task).
1597
+ if (req.method === 'POST' && req.url === '/api/crons/pause') {
1598
+ try {
1599
+ const { id, paused } = JSON.parse(await readBody());
1600
+ if (typeof id !== 'string' || !CRON_ID_RE.test(id)) { sendJson(400, { error: 'Invalid cron id' }); return; }
1601
+ const crons = readCronsConfig();
1602
+ const idx = crons.findIndex((c) => c.id === id);
1603
+ if (idx < 0) { sendJson(404, { error: 'Cron not found' }); return; }
1604
+ crons[idx] = { ...crons[idx], paused: !!paused };
1605
+ fs.writeFileSync(CRONS_PATH, JSON.stringify(crons, null, 2) + '\n', 'utf-8');
1606
+ sendJson(200, { ok: true });
1607
+ } catch (err: any) {
1608
+ sendJson(500, { error: err.message });
1609
+ }
1610
+ return;
1611
+ }
1612
+
1613
+ // POST /api/crons/delete — remove the cron and its task file (idempotent).
1614
+ if (req.method === 'POST' && req.url === '/api/crons/delete') {
1615
+ try {
1616
+ const { id } = JSON.parse(await readBody());
1617
+ if (typeof id !== 'string' || !CRON_ID_RE.test(id)) { sendJson(400, { error: 'Invalid cron id' }); return; }
1618
+ const crons = readCronsConfig();
1619
+ const remaining = crons.filter((c) => c.id !== id);
1620
+ fs.writeFileSync(CRONS_PATH, JSON.stringify(remaining, null, 2) + '\n', 'utf-8');
1621
+ try { fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${id}.md`)); } catch {}
1622
+ sendJson(200, { ok: true });
1623
+ } catch (err: any) {
1624
+ sendJson(500, { error: err.message });
1625
+ }
1626
+ return;
1627
+ }
1628
+
1629
+ // GET /api/crons/task?id=<id> — download the cron's task file (authed → blob on the client).
1630
+ if (req.method === 'GET' && req.url.startsWith('/api/crons/task')) {
1631
+ try {
1632
+ const id = new URL(req.url, `http://${req.headers.host}`).searchParams.get('id') || '';
1633
+ if (!CRON_ID_RE.test(id)) { sendJson(400, { error: 'Invalid cron id' }); return; }
1634
+ const taskPath = path.join(WORKSPACE_DIR, 'tasks', `${id}.md`);
1635
+ if (!fs.existsSync(taskPath)) { sendJson(404, { error: 'Task file not found' }); return; }
1636
+ const content = fs.readFileSync(taskPath, 'utf-8');
1637
+ res.writeHead(200, {
1638
+ 'Content-Type': 'text/markdown; charset=utf-8',
1639
+ 'Content-Disposition': `attachment; filename="${id}.md"`,
1640
+ });
1641
+ res.end(content);
1642
+ } catch (err: any) {
1643
+ sendJson(500, { error: err.message });
1644
+ }
1645
+ return;
1646
+ }
1647
+
1648
+ sendJson(404, { error: 'Not found' });
1649
+ return;
1650
+ }
1651
+
1506
1652
  // ── Agent API — SDK gateway for workspace code ──
1507
1653
  if (req.url?.startsWith('/api/agent/')) {
1508
1654
  const agentPath = req.url.split('?')[0];
@@ -26,6 +26,7 @@ interface CronConfig {
26
26
  task: string;
27
27
  enabled: boolean;
28
28
  oneShot?: boolean;
29
+ paused?: boolean; // user-controlled via /api/crons/pause; scheduler skips when true.
29
30
  }
30
31
 
31
32
  interface SchedulerOpts {
@@ -41,7 +42,7 @@ const lastCronRuns = new Map<string, number>();
41
42
  let intervalHandle: ReturnType<typeof setInterval> | null = null;
42
43
  let schedulerOpts: SchedulerOpts | null = null;
43
44
 
44
- function readPulseConfig(): PulseConfig {
45
+ export function readPulseConfig(): PulseConfig {
45
46
  try {
46
47
  const raw = fs.readFileSync(PULSE_FILE, 'utf-8');
47
48
  const parsed = JSON.parse(raw);
@@ -55,7 +56,7 @@ function readPulseConfig(): PulseConfig {
55
56
  }
56
57
  }
57
58
 
58
- function readCronsConfig(): CronConfig[] {
59
+ export function readCronsConfig(): CronConfig[] {
59
60
  try {
60
61
  const raw = fs.readFileSync(CRONS_FILE, 'utf-8');
61
62
  const parsed = JSON.parse(raw);
@@ -257,7 +258,9 @@ function tick() {
257
258
  const crons = readCronsConfig();
258
259
 
259
260
  for (const cron of crons) {
260
- if (!cron.enabled || !cron.id || !cron.schedule) continue;
261
+ // `paused === true` (strict) so a missing/false flag from pre-pause CRONS.json never skips.
262
+ // Checked before cronMatchesNow/lastCronRuns so a paused cron neither fires nor advances state.
263
+ if (!cron.enabled || cron.paused === true || !cron.id || !cron.schedule) continue;
261
264
 
262
265
  const matches = cronMatchesNow(cron.schedule);
263
266
  const nowDate = new Date();
@@ -312,6 +315,46 @@ function tick() {
312
315
  }
313
316
  }
314
317
 
318
+ /** ISO of the next occurrence, or null if the schedule is unparseable / has no future occurrence. */
319
+ export function nextRunISO(schedule: string): string | null {
320
+ try {
321
+ // .next() throws both on a parse error and when there is no future occurrence — single parse.
322
+ return CronExpressionParser.parse(schedule).next().toISOString();
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+
328
+ /** Humanize a cron expression for the settings UI; falls back to the raw expression when a field
329
+ * is out of range or the pattern isn't one we can describe accurately (so the label never lies). */
330
+ export function describeCron(schedule: string): string {
331
+ const parts = schedule.trim().split(/\s+/);
332
+ if (parts.length !== 5) return schedule; // 6-field / @macro — show raw, don't lie
333
+ const [min, hr, dom, mon, dow] = parts;
334
+ const star = (s: string) => s === '*';
335
+ const two = (s: string) => String(s).padStart(2, '0');
336
+ const inRange = (s: string, lo: number, hi: number) => /^\d+$/.test(s) && Number(s) >= lo && Number(s) <= hi;
337
+ const at = (inRange(hr, 0, 23) && inRange(min, 0, 59)) ? `${two(hr)}:${two(min)}` : null;
338
+
339
+ // `*/N` only yields an even interval when N divides the field's range (60 min / 24 hr); otherwise
340
+ // it wraps at the top of the range and the "every N" label would be wrong — fall through to raw.
341
+ const everyMin = min.match(/^\*\/(\d+)$/);
342
+ if (everyMin && Number(everyMin[1]) >= 1 && 60 % Number(everyMin[1]) === 0 && star(hr) && star(dom) && star(mon) && star(dow)) {
343
+ return `Every ${everyMin[1]} minute${everyMin[1] === '1' ? '' : 's'}`;
344
+ }
345
+ const everyHr = hr.match(/^\*\/(\d+)$/);
346
+ if (everyHr && Number(everyHr[1]) >= 1 && 24 % Number(everyHr[1]) === 0 && inRange(min, 0, 59) && star(dom) && star(mon) && star(dow)) {
347
+ return `Every ${everyHr[1]} hour${everyHr[1] === '1' ? '' : 's'}${min === '0' ? '' : ` at :${two(min)}`}`;
348
+ }
349
+ if (at && star(dom) && star(mon) && star(dow)) return `Every day at ${at}`;
350
+ const DOW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
351
+ if (at && star(dom) && star(mon) && /^[0-7]$/.test(dow)) return `Every ${DOW[Number(dow) % 7]} at ${at}`; // 7 = Sunday alias
352
+ if (at && inRange(dom, 1, 31) && star(mon) && star(dow)) return `Monthly on day ${dom} at ${at}`;
353
+ const MON = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
354
+ if (at && inRange(dom, 1, 31) && inRange(mon, 1, 12) && star(dow)) return `On ${MON[Number(mon)]} ${dom} at ${at}`;
355
+ return schedule;
356
+ }
357
+
315
358
  export function startScheduler(opts: SchedulerOpts) {
316
359
  schedulerOpts = opts;
317
360
  lastPulseTime = Date.now();
@@ -151,7 +151,9 @@ Your human can ask you to:
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
- 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.
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
+
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.
155
157
 
156
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.
157
159
 
@@ -151,7 +151,9 @@ Your human can ask you to:
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
- 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.
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
+
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.
155
157
 
156
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.
157
159
 
@@ -151,7 +151,9 @@ Your human can ask you to:
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
- 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.
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
+
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.
155
157
 
156
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.
157
159