beecork 1.4.11 → 1.5.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 (66) hide show
  1. package/dist/channels/admin.d.ts +10 -0
  2. package/dist/channels/admin.js +20 -0
  3. package/dist/channels/command-handler.d.ts +2 -10
  4. package/dist/channels/command-handler.js +47 -73
  5. package/dist/channels/discord.d.ts +1 -3
  6. package/dist/channels/discord.js +28 -28
  7. package/dist/channels/loader.js +0 -1
  8. package/dist/channels/send-helpers.d.ts +19 -0
  9. package/dist/channels/send-helpers.js +21 -0
  10. package/dist/channels/telegram.d.ts +1 -9
  11. package/dist/channels/telegram.js +46 -71
  12. package/dist/channels/types.d.ts +2 -10
  13. package/dist/channels/voice-state.d.ts +29 -0
  14. package/dist/channels/voice-state.js +43 -0
  15. package/dist/channels/webhook.d.ts +1 -1
  16. package/dist/channels/webhook.js +68 -24
  17. package/dist/channels/whatsapp.d.ts +1 -3
  18. package/dist/channels/whatsapp.js +79 -74
  19. package/dist/cli/doctor.js +5 -2
  20. package/dist/cli/handoff.js +6 -6
  21. package/dist/config.d.ts +5 -1
  22. package/dist/config.js +17 -14
  23. package/dist/daemon.js +29 -17
  24. package/dist/dashboard/html.js +20 -8
  25. package/dist/dashboard/routes.d.ts +17 -0
  26. package/dist/dashboard/routes.js +559 -0
  27. package/dist/dashboard/server.js +33 -488
  28. package/dist/db/index.js +16 -2
  29. package/dist/db/migrations.js +44 -8
  30. package/dist/mcp/handlers.d.ts +37 -0
  31. package/dist/mcp/handlers.js +451 -0
  32. package/dist/mcp/server.js +25 -849
  33. package/dist/mcp/tool-definitions.d.ts +1225 -0
  34. package/dist/mcp/tool-definitions.js +364 -0
  35. package/dist/media/index.d.ts +2 -7
  36. package/dist/media/index.js +1 -1
  37. package/dist/observability/analytics.d.ts +1 -1
  38. package/dist/observability/analytics.js +6 -3
  39. package/dist/projects/index.d.ts +3 -2
  40. package/dist/projects/index.js +2 -2
  41. package/dist/projects/manager.d.ts +1 -3
  42. package/dist/projects/manager.js +26 -25
  43. package/dist/projects/router.d.ts +10 -0
  44. package/dist/projects/router.js +28 -0
  45. package/dist/session/manager.d.ts +4 -0
  46. package/dist/session/manager.js +48 -42
  47. package/dist/session/subprocess.d.ts +1 -0
  48. package/dist/session/subprocess.js +21 -0
  49. package/dist/session/tab-store.d.ts +28 -0
  50. package/dist/session/tab-store.js +77 -0
  51. package/dist/tasks/scheduler.d.ts +6 -0
  52. package/dist/tasks/scheduler.js +52 -13
  53. package/dist/tasks/store.js +6 -6
  54. package/dist/timeline/query.js +6 -2
  55. package/dist/types.d.ts +15 -0
  56. package/dist/util/paths.d.ts +1 -0
  57. package/dist/util/paths.js +4 -1
  58. package/dist/util/rate-limiter.js +8 -0
  59. package/dist/util/text.d.ts +21 -1
  60. package/dist/util/text.js +25 -1
  61. package/dist/watchers/scheduler.js +2 -3
  62. package/package.json +1 -1
  63. package/dist/users/index.d.ts +0 -2
  64. package/dist/users/index.js +0 -1
  65. package/dist/users/service.d.ts +0 -17
  66. package/dist/users/service.js +0 -46
@@ -2,24 +2,12 @@ import { v4 as uuidv4 } from 'uuid';
2
2
  import { ClaudeSubprocess } from './subprocess.js';
3
3
  import { CircuitBreaker } from './circuit-breaker.js';
4
4
  import { ContextMonitor } from './context-monitor.js';
5
+ import { TabStore } from './tab-store.js';
5
6
  import { getDb } from '../db/index.js';
6
7
  import { resolveWorkingDir, validateTabName } from '../config.js';
7
8
  import { logger } from '../util/logger.js';
8
9
  import { logActivity } from '../timeline/index.js';
9
10
  import { getAllKnowledge, formatKnowledgeForContext } from '../knowledge/index.js';
10
- function rowToTab(row) {
11
- return {
12
- id: row.id,
13
- name: row.name,
14
- sessionId: row.session_id,
15
- status: row.status,
16
- workingDir: row.working_dir,
17
- createdAt: row.created_at,
18
- lastActivityAt: row.last_activity_at,
19
- pid: row.pid,
20
- systemPrompt: row.system_prompt,
21
- };
22
- }
23
11
  const MAX_QUEUE_SIZE = 10;
24
12
  let pendingPollCount = 0;
25
13
  export class TabManager {
@@ -88,17 +76,14 @@ export class TabManager {
88
76
  }
89
77
  /** Get all tabs from the database */
90
78
  listTabs() {
91
- const db = getDb();
92
- return db.prepare('SELECT * FROM tabs ORDER BY last_activity_at DESC').all().map(rowToTab);
79
+ return TabStore.listAll();
93
80
  }
94
81
  /** Get a specific tab */
95
82
  getTab(tabName) {
96
- const db = getDb();
97
- return this.queryTab(db, tabName);
83
+ return TabStore.findByName(tabName);
98
84
  }
99
- queryTab(db, tabName) {
100
- const row = db.prepare('SELECT * FROM tabs WHERE name = ?').get(tabName);
101
- return row ? rowToTab(row) : undefined;
85
+ queryTab(_db, tabName) {
86
+ return TabStore.findByName(tabName);
102
87
  }
103
88
  /** Stop a tab's running subprocess */
104
89
  stopTab(tabName) {
@@ -109,6 +94,15 @@ export class TabManager {
109
94
  this.updateTabStatus(tabName, 'stopped');
110
95
  this.clearQueue(tabName);
111
96
  }
97
+ /** Update a tab's system_prompt. Returns true if the tab existed. */
98
+ setSystemPrompt(tabName, systemPrompt) {
99
+ return TabStore.setSystemPrompt(tabName, systemPrompt);
100
+ }
101
+ /** Close a tab — stop subprocess and delete its rows. Returns true if the tab existed. */
102
+ closeTab(tabName) {
103
+ this.stopTab(tabName);
104
+ return TabStore.deleteWithMessages(tabName);
105
+ }
112
106
  /** Stop all running subprocesses (clean shutdown) */
113
107
  stopAll() {
114
108
  for (const [tabName, sub] of this.subprocesses) {
@@ -132,17 +126,24 @@ export class TabManager {
132
126
  const pending = db.prepare('SELECT * FROM pending_messages WHERE processed = 0 ORDER BY created_at ASC LIMIT 50').all();
133
127
  if (pending.length === 0)
134
128
  return;
129
+ const markProcessed = db.prepare('UPDATE pending_messages SET processed = 1 WHERE id = ?');
135
130
  for (const msg of pending) {
136
- db.prepare('UPDATE pending_messages SET processed = 1 WHERE id = ?').run(msg.id);
131
+ // Mark processed AFTER delivery resolves (success OR failure) so a crash
132
+ // mid-loop doesn't leave the row both unprocessed in the DB and dropped
133
+ // from the in-memory iteration. Failures still mark processed — we don't
134
+ // want infinite retries — but the failure path also logs and notifies.
137
135
  if (msg.type === 'notification') {
138
- // Route notifications to Telegram/WhatsApp via the notify callback
139
- this.onNotify?.(msg.message).catch(err => logger.warn('Notify failed:', err));
136
+ this.onNotify?.(msg.message)
137
+ .catch(err => logger.warn('Notify failed:', err))
138
+ .finally(() => markProcessed.run(msg.id));
140
139
  }
141
140
  else {
142
- // Route regular messages to tabs
143
- this.sendMessage(msg.tab_name, msg.message).catch(err => {
141
+ this.sendMessage(msg.tab_name, msg.message)
142
+ .catch(err => {
144
143
  logger.error(`Failed to process pending message for tab ${msg.tab_name}:`, err);
145
- });
144
+ this.onNotify?.(`Pending message for tab "${msg.tab_name}" failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
145
+ })
146
+ .finally(() => markProcessed.run(msg.id));
146
147
  }
147
148
  }
148
149
  }
@@ -279,26 +280,31 @@ export class TabManager {
279
280
  db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?')
280
281
  .run('idle', new Date().toISOString(), tab.name);
281
282
  logActivity('task_completed', 'Message processed', { tabName: tab.name, durationMs: result.durationMs, costUsd: result.costUsd, details: result.text.slice(0, 500) });
282
- // Context window compaction: if checkpoint was triggered, restart with summary
283
+ // Context window compaction: if checkpoint was triggered, restart with summary.
283
284
  const currentDepth = compactionDepth ?? 0;
284
285
  if (checkpointTriggered && !result.error && result.text && currentDepth < 2) {
285
286
  logger.info(`[${tab.name}] Compacting context (depth ${currentDepth + 1}/2) — requesting summary then restarting session`);
286
287
  this.onNotify?.(`🔄 [${tab.name}] Context window full — compacting and continuing...`).catch(err => logger.warn('Notify failed:', err));
287
- // Ask Claude for a structured summary
288
- const summaryPrompt = 'Summarize your progress in this session concisely: completed steps, current state, remaining steps, and all important identifiers (file paths, URLs, variable names). Output ONLY the summary.';
289
- this.sendMessage(tab.name, summaryPrompt, { _compactionDepth: currentDepth + 1 }).then(summaryResult => {
290
- // Reset session: new session ID so next message starts fresh with summary context
291
- const newSessionId = uuidv4();
292
- db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(newSessionId, tab.id);
293
- logger.info(`[${tab.name}] Context compacted new session ${newSessionId.slice(0, 8)}...`);
294
- // Continue with original goal using the summary as in-prompt context (not persisted)
295
- const continuationPrompt = `[CONTEXT RESTORED FROM PREVIOUS SESSION]\n${summaryResult.text}\n\n[Continue the original task: "${enrichedPrompt.slice(0, 500)}"]`;
296
- this.sendMessage(tab.name, continuationPrompt, { onTextChunk, _compactionDepth: currentDepth + 1 }).then(resolve).catch(reject);
297
- }).catch(err => {
298
- logger.error(`[${tab.name}] Compaction failed:`, err);
299
- resolve(result); // Fall back to returning the original result
300
- });
301
- return; // Don't resolve yet — compaction flow will resolve
288
+ // Async/await so any thrown error (from DB or sendMessage) routes through
289
+ // a single catch instead of the previous nested .then().catch() chain
290
+ // where a sync throw inside the inner .then could orphan the promise.
291
+ (async () => {
292
+ try {
293
+ const summaryPrompt = 'Summarize your progress in this session concisely: completed steps, current state, remaining steps, and all important identifiers (file paths, URLs, variable names). Output ONLY the summary.';
294
+ const summaryResult = await this.sendMessage(tab.name, summaryPrompt, { _compactionDepth: currentDepth + 1 });
295
+ const newSessionId = uuidv4();
296
+ db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(newSessionId, tab.id);
297
+ logger.info(`[${tab.name}] Context compacted new session ${newSessionId.slice(0, 8)}...`);
298
+ const continuationPrompt = `[CONTEXT RESTORED FROM PREVIOUS SESSION]\n${summaryResult.text}\n\n[Continue the original task: "${enrichedPrompt.slice(0, 500)}"]`;
299
+ const continuation = await this.sendMessage(tab.name, continuationPrompt, { onTextChunk, _compactionDepth: currentDepth + 1 });
300
+ resolve(continuation);
301
+ }
302
+ catch (err) {
303
+ logger.error(`[${tab.name}] Compaction failed:`, err);
304
+ resolve(result); // Fall back to original result so the user gets *something*.
305
+ }
306
+ })();
307
+ return; // Don't resolve here — the async compaction flow resolves.
302
308
  }
303
309
  // Check for delegation completion
304
310
  import('../delegation/manager.js').then(({ completeDelegation }) => {
@@ -12,6 +12,7 @@ export declare class ClaudeSubprocess {
12
12
  private proc;
13
13
  private buffer;
14
14
  private killTimer;
15
+ private runtimeTimer;
15
16
  readonly sessionId: string;
16
17
  constructor(tabName: string, workingDir: string, config: BeecorkConfig, sessionId?: string, tabSystemPrompt?: string | null | undefined);
17
18
  send(prompt: string, callbacks: SubprocessCallbacks, resume?: boolean): Promise<void>;
@@ -31,6 +31,7 @@ export class ClaudeSubprocess {
31
31
  proc = null;
32
32
  buffer = '';
33
33
  killTimer = null;
34
+ runtimeTimer = null;
34
35
  sessionId;
35
36
  constructor(tabName, workingDir, config, sessionId, tabSystemPrompt) {
36
37
  this.tabName = tabName;
@@ -77,6 +78,10 @@ export class ClaudeSubprocess {
77
78
  });
78
79
  this.proc.on('error', (err) => {
79
80
  this.proc = null;
81
+ if (this.runtimeTimer) {
82
+ clearTimeout(this.runtimeTimer);
83
+ this.runtimeTimer = null;
84
+ }
80
85
  callbacks.onError(err);
81
86
  });
82
87
  this.proc.on('exit', (code) => {
@@ -85,9 +90,25 @@ export class ClaudeSubprocess {
85
90
  clearTimeout(this.killTimer);
86
91
  this.killTimer = null;
87
92
  }
93
+ if (this.runtimeTimer) {
94
+ clearTimeout(this.runtimeTimer);
95
+ this.runtimeTimer = null;
96
+ }
88
97
  logger.info(`[${this.tabName}] Claude subprocess exited (code: ${code})`);
89
98
  callbacks.onExit(code);
90
99
  });
100
+ // Hard runtime cap so a wedged claude can't pin a tab forever.
101
+ // Default 30 minutes. Disable by setting maxRuntimeMs to 0.
102
+ const maxRuntimeMs = this.config.claudeCode.maxRuntimeMs ?? 30 * 60 * 1000;
103
+ if (maxRuntimeMs > 0) {
104
+ this.runtimeTimer = setTimeout(() => {
105
+ if (!this.proc)
106
+ return;
107
+ logger.warn(`[${this.tabName}] Subprocess exceeded maxRuntimeMs (${maxRuntimeMs}ms) — killing`);
108
+ callbacks.onError(new Error(`Subprocess timed out after ${Math.round(maxRuntimeMs / 1000)}s`));
109
+ this.kill();
110
+ }, maxRuntimeMs);
111
+ }
91
112
  }
92
113
  kill() {
93
114
  if (!this.proc)
@@ -0,0 +1,28 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { Tab, TabStatus } from '../types.js';
3
+ /**
4
+ * SQL helpers for the `tabs` table. Single place for any code that needs to
5
+ * read/write tab rows. TabManager owns subprocess lifecycle; TabStore owns
6
+ * the schema knowledge.
7
+ *
8
+ * All methods accept an optional `db` param so they're testable against an
9
+ * in-memory database. The no-arg form uses the singleton.
10
+ */
11
+ export declare const TabStore: {
12
+ listAll(db?: Database.Database): Tab[];
13
+ findByName(name: string, db?: Database.Database): Tab | undefined;
14
+ getIdByName(name: string, db?: Database.Database): string | undefined;
15
+ countAll(db?: Database.Database): number;
16
+ countRunning(db?: Database.Database): number;
17
+ /** All tabs marked 'running' — used by daemon crash-recovery on startup. */
18
+ findRunning(db?: Database.Database): Tab[];
19
+ /** Most-recently-active tab — used by MCP to surface "current" context. */
20
+ mostRecent(db?: Database.Database): Tab | undefined;
21
+ setStatus(name: string, status: TabStatus, db?: Database.Database): void;
22
+ setIdleById(id: string, db?: Database.Database): void;
23
+ /** Used by MCP close-tab to nudge daemon recovery loop. */
24
+ markRunningAsStopped(name: string, db?: Database.Database): void;
25
+ setSystemPrompt(name: string, systemPrompt: string, db?: Database.Database): boolean;
26
+ /** Delete a tab and all of its messages atomically. Returns true if it existed. */
27
+ deleteWithMessages(name: string, db?: Database.Database): boolean;
28
+ };
@@ -0,0 +1,77 @@
1
+ import { getDb } from '../db/index.js';
2
+ function rowToTab(row) {
3
+ return {
4
+ id: row.id,
5
+ name: row.name,
6
+ sessionId: row.session_id,
7
+ status: row.status,
8
+ workingDir: row.working_dir,
9
+ createdAt: row.created_at,
10
+ lastActivityAt: row.last_activity_at,
11
+ pid: row.pid,
12
+ systemPrompt: row.system_prompt,
13
+ };
14
+ }
15
+ /**
16
+ * SQL helpers for the `tabs` table. Single place for any code that needs to
17
+ * read/write tab rows. TabManager owns subprocess lifecycle; TabStore owns
18
+ * the schema knowledge.
19
+ *
20
+ * All methods accept an optional `db` param so they're testable against an
21
+ * in-memory database. The no-arg form uses the singleton.
22
+ */
23
+ export const TabStore = {
24
+ listAll(db = getDb()) {
25
+ return db.prepare('SELECT * FROM tabs ORDER BY last_activity_at DESC').all().map(rowToTab);
26
+ },
27
+ findByName(name, db = getDb()) {
28
+ const row = db.prepare('SELECT * FROM tabs WHERE name = ?').get(name);
29
+ return row ? rowToTab(row) : undefined;
30
+ },
31
+ getIdByName(name, db = getDb()) {
32
+ const row = db.prepare('SELECT id FROM tabs WHERE name = ?').get(name);
33
+ return row?.id;
34
+ },
35
+ countAll(db = getDb()) {
36
+ return db.prepare('SELECT COUNT(*) as c FROM tabs').get().c;
37
+ },
38
+ countRunning(db = getDb()) {
39
+ return db.prepare("SELECT COUNT(*) as c FROM tabs WHERE status = 'running'").get().c;
40
+ },
41
+ /** All tabs marked 'running' — used by daemon crash-recovery on startup. */
42
+ findRunning(db = getDb()) {
43
+ return db.prepare("SELECT * FROM tabs WHERE status = 'running'").all().map(rowToTab);
44
+ },
45
+ /** Most-recently-active tab — used by MCP to surface "current" context. */
46
+ mostRecent(db = getDb()) {
47
+ const row = db.prepare('SELECT * FROM tabs ORDER BY last_activity_at DESC LIMIT 1').get();
48
+ return row ? rowToTab(row) : undefined;
49
+ },
50
+ setStatus(name, status, db = getDb()) {
51
+ db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?')
52
+ .run(status, new Date().toISOString(), name);
53
+ },
54
+ setIdleById(id, db = getDb()) {
55
+ db.prepare("UPDATE tabs SET status = 'idle', pid = NULL WHERE id = ?").run(id);
56
+ },
57
+ /** Used by MCP close-tab to nudge daemon recovery loop. */
58
+ markRunningAsStopped(name, db = getDb()) {
59
+ db.prepare("UPDATE tabs SET status = 'stopped', pid = NULL WHERE name = ? AND status = 'running'").run(name);
60
+ },
61
+ setSystemPrompt(name, systemPrompt, db = getDb()) {
62
+ const result = db.prepare('UPDATE tabs SET system_prompt = ? WHERE name = ?').run(systemPrompt, name);
63
+ return result.changes > 0;
64
+ },
65
+ /** Delete a tab and all of its messages atomically. Returns true if it existed. */
66
+ deleteWithMessages(name, db = getDb()) {
67
+ const id = this.getIdByName(name, db);
68
+ if (!id)
69
+ return false;
70
+ const tx = db.transaction(() => {
71
+ db.prepare('DELETE FROM messages WHERE tab_id = ?').run(id);
72
+ db.prepare('DELETE FROM tabs WHERE id = ?').run(id);
73
+ });
74
+ tx();
75
+ return true;
76
+ },
77
+ };
@@ -30,3 +30,9 @@ export declare class TaskScheduler {
30
30
  export declare function intervalToMs(interval: string): number | null;
31
31
  /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
32
32
  export declare function intervalToCron(interval: string): string | null;
33
+ /**
34
+ * Validate a schedule string for a given scheduleType. Returns an error string
35
+ * on invalid input or null when valid. Used by MCP + dashboard before insert
36
+ * so misconfigured tasks fail loud instead of silently never firing.
37
+ */
38
+ export declare function validateSchedule(scheduleType: string, schedule: string): string | null;
@@ -207,28 +207,33 @@ export class TaskScheduler {
207
207
  }
208
208
  }
209
209
  }
210
- /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
211
- export function intervalToMs(interval) {
210
+ /** Parse a "1w2d3h45m"-style interval into its parts. Returns null on invalid input. */
211
+ function parseInterval(interval) {
212
212
  const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
213
213
  if (!match || match.slice(1).every(g => g === undefined))
214
214
  return null;
215
- const weeks = parseInt(match[1] || '0', 10);
216
- const days = parseInt(match[2] || '0', 10);
217
- const hours = parseInt(match[3] || '0', 10);
218
- const mins = parseInt(match[4] || '0', 10);
215
+ return {
216
+ weeks: parseInt(match[1] || '0', 10),
217
+ days: parseInt(match[2] || '0', 10),
218
+ hours: parseInt(match[3] || '0', 10),
219
+ mins: parseInt(match[4] || '0', 10),
220
+ };
221
+ }
222
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
223
+ export function intervalToMs(interval) {
224
+ const parts = parseInterval(interval);
225
+ if (!parts)
226
+ return null;
227
+ const { weeks, days, hours, mins } = parts;
219
228
  const totalMs = ((weeks * 7 * 24 * 60) + (days * 24 * 60) + (hours * 60) + mins) * 60 * 1000;
220
229
  return totalMs > 0 ? totalMs : null;
221
230
  }
222
231
  /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
223
232
  export function intervalToCron(interval) {
224
- // Try combined format: 1h30m, 2h, 30m, 1d, 2w
225
- const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
226
- if (!match || match.slice(1).every(g => g === undefined))
233
+ const parts = parseInterval(interval);
234
+ if (!parts)
227
235
  return null;
228
- const weeks = parseInt(match[1] || '0', 10);
229
- const days = parseInt(match[2] || '0', 10);
230
- const hours = parseInt(match[3] || '0', 10);
231
- const mins = parseInt(match[4] || '0', 10);
236
+ const { weeks, days, hours, mins } = parts;
232
237
  // Convert to total minutes for simple intervals
233
238
  const totalMins = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + mins;
234
239
  if (totalMins <= 0)
@@ -248,3 +253,37 @@ export function intervalToCron(interval) {
248
253
  // Combined or large intervals -- return null, handled by intervalToMs fallback
249
254
  return null;
250
255
  }
256
+ /**
257
+ * Validate a schedule string for a given scheduleType. Returns an error string
258
+ * on invalid input or null when valid. Used by MCP + dashboard before insert
259
+ * so misconfigured tasks fail loud instead of silently never firing.
260
+ */
261
+ export function validateSchedule(scheduleType, schedule) {
262
+ if (!schedule)
263
+ return 'schedule is required';
264
+ switch (scheduleType) {
265
+ case 'at': {
266
+ const ts = Date.parse(schedule);
267
+ if (Number.isNaN(ts))
268
+ return `"${schedule}" is not a valid ISO datetime`;
269
+ return null;
270
+ }
271
+ case 'every': {
272
+ if (intervalToMs(schedule) === null) {
273
+ return `"${schedule}" is not a valid interval. Use formats like "30m", "2h", "1d", "1h30m".`;
274
+ }
275
+ return null;
276
+ }
277
+ case 'cron': {
278
+ try {
279
+ CronExpressionParser.parse(schedule);
280
+ return null;
281
+ }
282
+ catch (err) {
283
+ return `"${schedule}" is not a valid cron expression: ${err instanceof Error ? err.message : String(err)}`;
284
+ }
285
+ }
286
+ default:
287
+ return `unknown scheduleType "${scheduleType}"`;
288
+ }
289
+ }
@@ -18,7 +18,7 @@ export class TaskStore {
18
18
  }
19
19
  list() {
20
20
  const db = getDb();
21
- return db.prepare('SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at').all('local').map(rowToTask);
21
+ return db.prepare('SELECT * FROM tasks ORDER BY created_at').all().map(rowToTask);
22
22
  }
23
23
  get(id) {
24
24
  const db = getDb();
@@ -27,8 +27,8 @@ export class TaskStore {
27
27
  }
28
28
  add(job) {
29
29
  const db = getDb();
30
- db.prepare(`INSERT INTO tasks (id, name, schedule_type, schedule, tab_name, message, payload_type, enabled, user_id, created_at, last_run_at, next_run_at)
31
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(job.id, job.name, job.scheduleType, job.schedule, job.tabName, job.message, job.payloadType || 'agentTurn', job.enabled ? 1 : 0, 'local', job.createdAt, job.lastRunAt, job.nextRunAt);
30
+ db.prepare(`INSERT INTO tasks (id, name, schedule_type, schedule, tab_name, message, payload_type, enabled, created_at, last_run_at, next_run_at)
31
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(job.id, job.name, job.scheduleType, job.schedule, job.tabName, job.message, job.payloadType || 'agentTurn', job.enabled ? 1 : 0, job.createdAt, job.lastRunAt, job.nextRunAt);
32
32
  }
33
33
  update(id, updates) {
34
34
  const db = getDb();
@@ -71,11 +71,11 @@ export class TaskStore {
71
71
  const jobs = data.jobs || [];
72
72
  if (jobs.length === 0)
73
73
  return;
74
- const insert = db.prepare(`INSERT OR IGNORE INTO tasks (id, name, schedule_type, schedule, tab_name, message, enabled, user_id, created_at, last_run_at, next_run_at)
75
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
74
+ const insert = db.prepare(`INSERT OR IGNORE INTO tasks (id, name, schedule_type, schedule, tab_name, message, enabled, created_at, last_run_at, next_run_at)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
76
76
  const tx = db.transaction(() => {
77
77
  for (const j of jobs) {
78
- insert.run(j.id, j.name, j.scheduleType, j.schedule, j.tabName || 'default', j.message, j.enabled ? 1 : 0, 'local', j.createdAt, j.lastRunAt, j.nextRunAt);
78
+ insert.run(j.id, j.name, j.scheduleType, j.schedule, j.tabName || 'default', j.message, j.enabled ? 1 : 0, j.createdAt, j.lastRunAt, j.nextRunAt);
79
79
  }
80
80
  });
81
81
  tx();
@@ -12,8 +12,12 @@ export function getTimeline(options) {
12
12
  const conditions = [];
13
13
  const params = [];
14
14
  if (options?.date) {
15
- conditions.push('date(created_at) = ?');
16
- params.push(options.date);
15
+ // Sargable range so idx_activity_log_created can serve the query.
16
+ // `date(created_at) = ?` is non-sargable and forces a full scan.
17
+ const dayStart = `${options.date} 00:00:00`;
18
+ const dayEnd = `${options.date} 23:59:59.999`;
19
+ conditions.push('created_at >= ? AND created_at <= ?');
20
+ params.push(dayStart, dayEnd);
17
21
  }
18
22
  if (options?.tabName) {
19
23
  conditions.push('tab_name = ?');
package/dist/types.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface ClaudeCodeConfig {
8
8
  defaultFlags: string[];
9
9
  maxBudgetUsd?: number;
10
10
  computerUse?: boolean;
11
+ /** Hard timeout per subprocess turn. Default 30 min. Set to 0 to disable. */
12
+ maxRuntimeMs?: number;
11
13
  }
12
14
  export interface MemoryConfig {
13
15
  dbPath: string;
@@ -25,6 +27,8 @@ export interface WhatsAppConfig {
25
27
  mode: 'baileys';
26
28
  sessionPath: string;
27
29
  allowedNumbers: string[];
30
+ /** Optional admin phone number. Defaults to allowedNumbers[0]. */
31
+ adminNumber?: string;
28
32
  }
29
33
  export interface VoiceConfig {
30
34
  sttProvider: 'whisper-api' | 'none';
@@ -37,6 +41,8 @@ export interface VoiceConfig {
37
41
  export interface DiscordConfig {
38
42
  token: string;
39
43
  allowedUserIds?: string[];
44
+ /** Optional admin user ID. Defaults to allowedUserIds[0]. */
45
+ adminUserId?: string;
40
46
  }
41
47
  export interface WebhookConfig {
42
48
  enabled: boolean;
@@ -65,6 +71,15 @@ export interface MediaGeneratorConfig {
65
71
  model?: string;
66
72
  style?: string;
67
73
  }
74
+ /** A media file attached to a message. Shared by channels + util/text + media. */
75
+ export interface MediaAttachment {
76
+ type: 'image' | 'audio' | 'video' | 'document' | 'voice';
77
+ mimeType: string;
78
+ filePath: string;
79
+ fileName?: string;
80
+ duration?: number;
81
+ caption?: string;
82
+ }
68
83
  export interface BeecorkConfig {
69
84
  telegram: TelegramConfig;
70
85
  whatsapp?: WhatsAppConfig;
@@ -7,5 +7,6 @@ export declare function getLogsDir(): string;
7
7
  export declare function getPidPath(): string;
8
8
  export declare function getRuntimeInfoPath(): string;
9
9
  export declare function getCronReloadSignalPath(): string;
10
+ export declare function getWatcherReloadSignalPath(): string;
10
11
  export declare function ensureBeecorkDirs(): void;
11
12
  export declare function expandHome(p: string): string;
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import fs from 'node:fs';
4
4
  const BEECORK_DIR = '.beecork';
5
5
  export function getBeecorkHome() {
6
- return path.join(os.homedir(), BEECORK_DIR);
6
+ return process.env.BEECORK_HOME || path.join(os.homedir(), BEECORK_DIR);
7
7
  }
8
8
  export function getConfigPath() {
9
9
  return path.join(getBeecorkHome(), 'config.json');
@@ -29,6 +29,9 @@ export function getRuntimeInfoPath() {
29
29
  export function getCronReloadSignalPath() {
30
30
  return path.join(getBeecorkHome(), '.cron-reload');
31
31
  }
32
+ export function getWatcherReloadSignalPath() {
33
+ return path.join(getBeecorkHome(), '.watcher-reload');
34
+ }
32
35
  export function ensureBeecorkDirs() {
33
36
  const home = getBeecorkHome();
34
37
  fs.mkdirSync(home, { recursive: true });
@@ -21,6 +21,14 @@ export class RateLimiter {
21
21
  logger.warn(`Rate limit: global limit reached (${this.globalLimit}/min)`);
22
22
  return false;
23
23
  }
24
+ // Bound the per-key map so a daemon that has seen many one-off keys
25
+ // (e.g. thousands of Telegram groups over months) doesn't leak memory.
26
+ if (this.perKey.size > 1000) {
27
+ for (const [k, w] of this.perKey) {
28
+ if (now > w.resetAt)
29
+ this.perKey.delete(k);
30
+ }
31
+ }
24
32
  // Reset per-key window
25
33
  let keyWindow = this.perKey.get(key);
26
34
  if (!keyWindow || now > keyWindow.resetAt) {
@@ -1,4 +1,18 @@
1
- import type { MediaAttachment } from '../channels/types.js';
1
+ import type { MediaAttachment } from '../types.js';
2
+ /**
3
+ * Shared message-size limits. Single source of truth so caps don't drift
4
+ * between channels, MCP, and dashboard.
5
+ */
6
+ export declare const MESSAGE_LIMITS: {
7
+ /** Per-chunk send size (Telegram = 4096; other channels chunk to this too). */
8
+ readonly CHUNK: 4096;
9
+ /** HTTP request body cap (webhook + dashboard endpoints). */
10
+ readonly HTTP_BODY: number;
11
+ /** Webhook channel single-message payload. */
12
+ readonly WEBHOOK_PROMPT: 100000;
13
+ /** MCP tool content/message field. Tight because it goes through Claude's tool-result token budget. */
14
+ readonly MCP_CONTENT: 10240;
15
+ };
2
16
  /** Split long text into chunks that fit within a message limit */
3
17
  export declare function chunkText(text: string, maxLength?: number): string[];
4
18
  /** Format an ISO date as a human-readable relative time */
@@ -8,5 +22,11 @@ export declare function parseTabMessage(text: string): {
8
22
  tabName: string;
9
23
  prompt: string;
10
24
  };
25
+ /**
26
+ * Build the channel-side message text with an optional [tabName] prefix.
27
+ * Used by all three channels' sendResponse paths so the prefix format is
28
+ * consistent and the gate logic (skip "default", skip empty) lives in one place.
29
+ */
30
+ export declare function formatTabbedResponse(text: string, tabName?: string): string;
11
31
  /** Build prompt text from media attachments */
12
32
  export declare function buildMediaPrompt(media: MediaAttachment[], textPrompt: string): string;
package/dist/util/text.js CHANGED
@@ -1,4 +1,18 @@
1
- const DEFAULT_MAX_LENGTH = 4096;
1
+ /**
2
+ * Shared message-size limits. Single source of truth so caps don't drift
3
+ * between channels, MCP, and dashboard.
4
+ */
5
+ export const MESSAGE_LIMITS = {
6
+ /** Per-chunk send size (Telegram = 4096; other channels chunk to this too). */
7
+ CHUNK: 4096,
8
+ /** HTTP request body cap (webhook + dashboard endpoints). */
9
+ HTTP_BODY: 1024 * 1024, // 1 MB
10
+ /** Webhook channel single-message payload. */
11
+ WEBHOOK_PROMPT: 100_000, // 100 KB
12
+ /** MCP tool content/message field. Tight because it goes through Claude's tool-result token budget. */
13
+ MCP_CONTENT: 10_240, // 10 KB
14
+ };
15
+ const DEFAULT_MAX_LENGTH = MESSAGE_LIMITS.CHUNK;
2
16
  /** Split long text into chunks that fit within a message limit */
3
17
  export function chunkText(text, maxLength = DEFAULT_MAX_LENGTH) {
4
18
  if (text.length <= maxLength)
@@ -50,6 +64,16 @@ export function parseTabMessage(text) {
50
64
  }
51
65
  return { tabName: 'default', prompt: text };
52
66
  }
67
+ /**
68
+ * Build the channel-side message text with an optional [tabName] prefix.
69
+ * Used by all three channels' sendResponse paths so the prefix format is
70
+ * consistent and the gate logic (skip "default", skip empty) lives in one place.
71
+ */
72
+ export function formatTabbedResponse(text, tabName) {
73
+ if (!tabName || tabName === 'default')
74
+ return text;
75
+ return `[${tabName}] ${text}`;
76
+ }
53
77
  /** Build prompt text from media attachments */
54
78
  export function buildMediaPrompt(media, textPrompt) {
55
79
  if (media.length === 0)
@@ -3,9 +3,8 @@ import path from 'node:path';
3
3
  import { WatcherStore } from './store.js';
4
4
  import { evaluateWatcher } from './evaluator.js';
5
5
  import { execAsync, intervalToMs } from '../tasks/scheduler.js';
6
- import { getBeecorkHome, getLogsDir } from '../util/paths.js';
6
+ import { getLogsDir, getWatcherReloadSignalPath } from '../util/paths.js';
7
7
  import { logger } from '../util/logger.js';
8
- const WATCHER_RELOAD_SIGNAL_NAME = '.watcher-reload';
9
8
  export class WatcherScheduler {
10
9
  store = new WatcherStore();
11
10
  // watcherId -> due time in ms epoch for next check
@@ -48,7 +47,7 @@ export class WatcherScheduler {
48
47
  }
49
48
  /** Check for the reload signal file and reload if present */
50
49
  checkForReload() {
51
- const signalPath = path.join(getBeecorkHome(), WATCHER_RELOAD_SIGNAL_NAME);
50
+ const signalPath = getWatcherReloadSignalPath();
52
51
  if (fs.existsSync(signalPath)) {
53
52
  try {
54
53
  fs.unlinkSync(signalPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beecork",
3
- "version": "1.4.11",
3
+ "version": "1.5.0",
4
4
  "description": "Claude Code always-on infrastructure — a phone number, a memory, and an alarm clock",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,2 +0,0 @@
1
- export { resolveUser, registerUser, linkIdentity, listUsers, hasAdmin } from './service.js';
2
- export type { User } from './service.js';
@@ -1 +0,0 @@
1
- export { resolveUser, registerUser, linkIdentity, listUsers, hasAdmin } from './service.js';