beecork 1.5.0 → 1.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 (119) hide show
  1. package/dist/capabilities/index.d.ts +1 -1
  2. package/dist/capabilities/index.js +1 -1
  3. package/dist/capabilities/manager.js +13 -9
  4. package/dist/capabilities/packs.js +3 -1
  5. package/dist/channels/command-handler.js +46 -14
  6. package/dist/channels/discord.d.ts +3 -6
  7. package/dist/channels/discord.js +40 -23
  8. package/dist/channels/index.d.ts +1 -1
  9. package/dist/channels/loader.js +13 -3
  10. package/dist/channels/pipeline.js +14 -5
  11. package/dist/channels/registry.d.ts +17 -1
  12. package/dist/channels/registry.js +33 -4
  13. package/dist/channels/telegram.d.ts +20 -5
  14. package/dist/channels/telegram.js +177 -42
  15. package/dist/channels/types.d.ts +11 -28
  16. package/dist/channels/voice-state.js +3 -1
  17. package/dist/channels/webhook.d.ts +1 -4
  18. package/dist/channels/webhook.js +26 -11
  19. package/dist/channels/whatsapp.d.ts +8 -4
  20. package/dist/channels/whatsapp.js +65 -29
  21. package/dist/cli/capabilities.js +4 -4
  22. package/dist/cli/channel.js +16 -6
  23. package/dist/cli/commands.js +12 -9
  24. package/dist/cli/doctor.js +80 -25
  25. package/dist/cli/handoff.d.ts +7 -14
  26. package/dist/cli/handoff.js +9 -44
  27. package/dist/cli/mcp.js +5 -5
  28. package/dist/cli/media.js +21 -8
  29. package/dist/cli/setup.js +9 -8
  30. package/dist/cli/store.js +29 -12
  31. package/dist/config.js +5 -10
  32. package/dist/daemon.js +88 -38
  33. package/dist/dashboard/html.js +80 -12
  34. package/dist/dashboard/routes.js +143 -79
  35. package/dist/dashboard/server.js +5 -1
  36. package/dist/db/connection.d.ts +29 -0
  37. package/dist/db/connection.js +37 -0
  38. package/dist/db/index.js +30 -12
  39. package/dist/db/migrations.js +84 -28
  40. package/dist/delegation/manager.js +10 -4
  41. package/dist/index.js +39 -59
  42. package/dist/knowledge/manager.js +26 -12
  43. package/dist/mcp/handlers.js +126 -57
  44. package/dist/mcp/server.js +20 -10
  45. package/dist/mcp/tool-definitions.js +68 -20
  46. package/dist/mcp/validate.d.ts +23 -0
  47. package/dist/mcp/validate.js +65 -0
  48. package/dist/media/factory.js +18 -14
  49. package/dist/media/generators/dall-e.js +2 -2
  50. package/dist/media/generators/kling.js +4 -4
  51. package/dist/media/generators/lyria.js +1 -1
  52. package/dist/media/generators/nano-banana.d.ts +1 -1
  53. package/dist/media/generators/nano-banana.js +2 -2
  54. package/dist/media/generators/poll-util.js +4 -4
  55. package/dist/media/generators/recraft.js +3 -3
  56. package/dist/media/generators/runway.js +4 -4
  57. package/dist/media/generators/stable-diffusion.js +2 -2
  58. package/dist/media/generators/veo.js +1 -1
  59. package/dist/media/index.js +1 -1
  60. package/dist/media/store.d.ts +7 -0
  61. package/dist/media/store.js +18 -4
  62. package/dist/media/types.d.ts +22 -0
  63. package/dist/notifications/index.d.ts +2 -4
  64. package/dist/notifications/index.js +6 -19
  65. package/dist/notifications/ntfy.js +3 -3
  66. package/dist/observability/analytics.js +35 -13
  67. package/dist/projects/index.d.ts +1 -1
  68. package/dist/projects/index.js +1 -1
  69. package/dist/projects/manager.d.ts +0 -4
  70. package/dist/projects/manager.js +51 -28
  71. package/dist/projects/router.d.ts +2 -0
  72. package/dist/projects/router.js +70 -45
  73. package/dist/service/install.js +15 -5
  74. package/dist/service/windows.js +1 -1
  75. package/dist/session/budget-guard.d.ts +20 -0
  76. package/dist/session/budget-guard.js +31 -0
  77. package/dist/session/circuit-breaker.d.ts +5 -3
  78. package/dist/session/circuit-breaker.js +45 -20
  79. package/dist/session/context-compactor.d.ts +32 -0
  80. package/dist/session/context-compactor.js +45 -0
  81. package/dist/session/context-monitor.js +2 -2
  82. package/dist/session/handoff.d.ts +21 -0
  83. package/dist/session/handoff.js +50 -0
  84. package/dist/session/manager.d.ts +17 -5
  85. package/dist/session/manager.js +153 -146
  86. package/dist/session/memory-store.d.ts +29 -0
  87. package/dist/session/memory-store.js +45 -0
  88. package/dist/session/message-queue.d.ts +28 -0
  89. package/dist/session/message-queue.js +52 -0
  90. package/dist/session/pending-dispatcher.d.ts +31 -0
  91. package/dist/session/pending-dispatcher.js +120 -0
  92. package/dist/session/pending-store.d.ts +60 -0
  93. package/dist/session/pending-store.js +118 -0
  94. package/dist/session/stale-session.d.ts +31 -0
  95. package/dist/session/stale-session.js +45 -0
  96. package/dist/session/subprocess.d.ts +2 -0
  97. package/dist/session/subprocess.js +33 -11
  98. package/dist/session/tab-store.js +4 -3
  99. package/dist/tasks/scheduler.d.ts +7 -0
  100. package/dist/tasks/scheduler.js +46 -6
  101. package/dist/tasks/store.js +20 -6
  102. package/dist/timeline/logger.js +3 -1
  103. package/dist/timeline/query.js +9 -3
  104. package/dist/types.d.ts +34 -9
  105. package/dist/util/auto-heal.js +15 -5
  106. package/dist/util/install-info.js +3 -1
  107. package/dist/util/logger.d.ts +1 -1
  108. package/dist/util/logger.js +63 -24
  109. package/dist/util/paths.d.ts +1 -0
  110. package/dist/util/paths.js +12 -2
  111. package/dist/util/retry.js +1 -1
  112. package/dist/util/text.js +13 -7
  113. package/dist/voice/index.js +5 -1
  114. package/dist/voice/stt.js +14 -6
  115. package/dist/voice/tts.js +1 -1
  116. package/dist/watchers/scheduler.js +9 -2
  117. package/package.json +18 -13
  118. package/dist/session/tool-classifier.d.ts +0 -4
  119. package/dist/session/tool-classifier.js +0 -56
@@ -125,8 +125,9 @@ export function getDashboardHtml(token) {
125
125
  <!-- Message input -->
126
126
  <div id="msg-input-area" class="hidden shrink-0 border-t border-bee-700 bg-bee-800 p-3">
127
127
  <form id="send-form" onsubmit="sendMessage(event)" class="flex gap-2">
128
+ <label for="msg-input" class="sr-only">Message</label>
128
129
  <input id="msg-input" type="text" placeholder="Send a message to this tab..."
129
- class="input-field flex-1 px-3 py-2 text-sm" autocomplete="off">
130
+ class="input-field flex-1 px-3 py-2 text-sm" autocomplete="off" aria-label="Message">
130
131
  <button type="submit" class="btn-primary px-4 py-2 text-sm">Send</button>
131
132
  </form>
132
133
  </div>
@@ -140,8 +141,9 @@ export function getDashboardHtml(token) {
140
141
  <div class="px-4 py-3 border-b border-bee-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
141
142
  <h2 class="text-sm font-semibold text-gray-300">Memories</h2>
142
143
  <div class="flex items-center gap-3 w-full sm:w-auto">
144
+ <label for="memory-search" class="sr-only">Search memories</label>
143
145
  <input id="memory-search" type="text" placeholder="Search..."
144
- class="input-field px-3 py-1.5 text-sm flex-1 sm:w-48" oninput="debounceMemorySearch()">
146
+ class="input-field px-3 py-1.5 text-sm flex-1 sm:w-48" oninput="debounceMemorySearch()" aria-label="Search memories">
145
147
  <button onclick="showCreateMemoryModal()" class="btn-ghost px-2 py-1 text-xs whitespace-nowrap">+ Add</button>
146
148
  <span id="memory-count" class="text-xs text-gray-500 whitespace-nowrap"></span>
147
149
  </div>
@@ -181,9 +183,8 @@ export function getDashboardHtml(token) {
181
183
  <div id="cost-chart" class="p-6"></div>
182
184
  </div>
183
185
  </div>
184
- </div>
185
186
 
186
- <!-- Update Panel -->
187
+ <!-- Update Panel — must live inside #app so it inherits the height/scroll setup -->
187
188
  <div id="panel-update" class="panel hidden h-full overflow-y-auto p-4">
188
189
  <div class="max-w-lg space-y-3">
189
190
  <div id="update-packages" class="space-y-3 text-sm text-gray-400">Checking for updates...</div>
@@ -198,10 +199,21 @@ export function getDashboardHtml(token) {
198
199
  <script>
199
200
  const API_TOKEN = ${JSON.stringify(token)};
200
201
 
202
+ // Scrub the auth token out of the URL bar once the cookie is set. The token
203
+ // is required for first-load but lingers in browser history / referrer-able
204
+ // copies of the URL otherwise. Referrer-Policy: no-referrer prevents the
205
+ // worst case; this is defense-in-depth.
206
+ if (location.search.includes('token=')) {
207
+ try { history.replaceState(null, '', location.pathname); } catch {}
208
+ }
209
+
201
210
  // State
202
211
  let selectedTab = null;
203
212
  let memorySearchTimer = null;
204
213
  let tabsData = [];
214
+ // Track last-seen message count per tab so the background refresh can skip
215
+ // the DOM rebuild entirely when nothing changed (M23 — fixes scroll jumping).
216
+ const lastMessageTotals = new Map();
205
217
 
206
218
  // Toast notifications
207
219
  function showToast(msg, isError) {
@@ -323,7 +335,12 @@ export function getDashboardHtml(token) {
323
335
 
324
336
  // --- Tabs ---
325
337
  async function loadTabs() {
326
- try { tabsData = await api('/api/tabs'); } catch { return; }
338
+ try { tabsData = await api('/api/tabs'); }
339
+ catch (err) {
340
+ const list = document.getElementById('tab-list');
341
+ if (list) list.innerHTML = '<p class="text-red-400 text-xs text-center py-8">Failed to load tabs (' + esc(err && err.message ? err.message : String(err)) + ')</p>';
342
+ return;
343
+ }
327
344
  const list = document.getElementById('tab-list');
328
345
  document.getElementById('tab-count').textContent = tabsData.length.toString();
329
346
 
@@ -335,7 +352,10 @@ export function getDashboardHtml(token) {
335
352
  list.innerHTML = tabsData.map(t => {
336
353
  const isActive = selectedTab === t.name ? ' active' : '';
337
354
  const cost = t.total_cost > 0 ? '$' + t.total_cost.toFixed(4) : '';
338
- return '<div class="tab-item px-3 py-2.5 cursor-pointer' + isActive + '" data-tab-name="' + esc(t.name) + '" role="button" tabindex="0" onclick="selectTab(\\'' + esc(t.name).replace(/'/g, "\\\\'") + '\\')" onkeydown="if(event.key===\\'Enter\\')selectTab(\\'' + esc(t.name).replace(/'/g, "\\\\'") + '\\')">' +
355
+ // esc() already replaces ' with &#39;, and tab names are regex-validated
356
+ // to [a-zA-Z0-9-] anyway, so the previous chained .replace(/'/g, ...) was
357
+ // dead code. Drop it for clarity.
358
+ return '<div class="tab-item px-3 py-2.5 cursor-pointer' + isActive + '" data-tab-name="' + esc(t.name) + '" role="button" tabindex="0" onclick="selectTab(\\'' + esc(t.name) + '\\')" onkeydown="if(event.key===\\'Enter\\')selectTab(\\'' + esc(t.name) + '\\')">' +
339
359
  '<div class="flex items-center justify-between">' +
340
360
  '<div class="flex items-center gap-2 min-w-0">' +
341
361
  '<span class="status-dot tab-status-dot status-' + esc(t.status) + '"></span>' +
@@ -368,15 +388,32 @@ export function getDashboardHtml(token) {
368
388
  document.getElementById('msg-tab-status').textContent = tab.status;
369
389
  }
370
390
 
371
- let data; try { data = await api('/api/tabs/' + encodeURIComponent(name) + '/messages?limit=100'); } catch { return; }
391
+ let data; try { data = await api('/api/tabs/' + encodeURIComponent(name) + '/messages?limit=100'); } catch (err) {
392
+ const list = document.getElementById('msg-list');
393
+ if (list) list.innerHTML = '<p class="text-red-400 text-sm text-center py-8">Failed to load messages (' + esc(err && err.message ? err.message : String(err)) + ')</p>';
394
+ return;
395
+ }
372
396
  const list = document.getElementById('msg-list');
373
397
  document.getElementById('msg-count').textContent = data.total + ' messages';
374
398
 
399
+ // Skip the full DOM rebuild when nothing changed — preserves user's scroll
400
+ // position, expanded <details> blocks, text selection. Without this guard
401
+ // the 8s background refresh kept teleporting the user to the top of the
402
+ // message list every tick.
403
+ const prevTotal = lastMessageTotals.get(name);
404
+ if (!fromUser && prevTotal === data.total) return;
405
+ lastMessageTotals.set(name, data.total);
406
+
375
407
  if (data.messages.length === 0) {
376
408
  list.innerHTML = '<p class="text-gray-600 text-sm text-center py-16">No messages in this tab</p>';
377
409
  return;
378
410
  }
379
411
 
412
+ // Capture scroll position relative to the bottom so we can restore after
413
+ // the rewrite. distanceFromBottom = scrollHeight - scrollTop - clientHeight
414
+ const wasNearBottom = !fromUser && (list.scrollHeight - list.scrollTop - list.clientHeight < 60);
415
+ const distanceFromBottom = list.scrollHeight - list.scrollTop;
416
+
380
417
  list.innerHTML = data.messages.map(m => {
381
418
  const cls = m.role === 'user' ? 'msg-user' : 'msg-assistant';
382
419
  const label = m.role === 'user' ? 'You' : 'Claude';
@@ -406,6 +443,13 @@ export function getDashboardHtml(token) {
406
443
  if (fromUser) {
407
444
  list.scrollTop = list.scrollHeight;
408
445
  document.getElementById('msg-input').focus();
446
+ } else if (wasNearBottom) {
447
+ // User was reading the latest messages — keep them pinned to the bottom.
448
+ list.scrollTop = list.scrollHeight;
449
+ } else {
450
+ // User was scrolled up reading older messages — restore their position
451
+ // relative to the bottom so new messages don't yank them around.
452
+ list.scrollTop = list.scrollHeight - distanceFromBottom;
409
453
  }
410
454
  }
411
455
 
@@ -492,7 +536,13 @@ export function getDashboardHtml(token) {
492
536
  // --- Memories ---
493
537
  async function loadMemories(query) {
494
538
  const q = query || document.getElementById('memory-search').value || '';
495
- let data; try { data = await api('/api/memories?limit=100&q=' + encodeURIComponent(q)); } catch { return; }
539
+ let data;
540
+ try { data = await api('/api/memories?limit=100&q=' + encodeURIComponent(q)); }
541
+ catch (err) {
542
+ const list = document.getElementById('memory-list');
543
+ if (list) list.innerHTML = '<p class="text-red-400 text-sm text-center py-8">Failed to load memories (' + esc(err && err.message ? err.message : String(err)) + ')</p>';
544
+ return;
545
+ }
496
546
  const list = document.getElementById('memory-list');
497
547
  document.getElementById('memory-count').textContent = data.total + ' total';
498
548
 
@@ -560,7 +610,13 @@ export function getDashboardHtml(token) {
560
610
 
561
611
  // --- Tasks (formerly Crons) ---
562
612
  async function loadCrons() {
563
- let crons; try { crons = await api('/api/tasks'); } catch { return; }
613
+ let crons;
614
+ try { crons = await api('/api/tasks'); }
615
+ catch (err) {
616
+ const list = document.getElementById('cron-list');
617
+ if (list) list.innerHTML = '<p class="text-red-400 text-sm text-center py-8">Failed to load tasks (' + esc(err && err.message ? err.message : String(err)) + ')</p>';
618
+ return;
619
+ }
564
620
  const list = document.getElementById('cron-list');
565
621
 
566
622
  if (crons.length === 0) {
@@ -577,7 +633,7 @@ export function getDashboardHtml(token) {
577
633
  '<span class="text-sm font-medium text-white">' + esc(c.name) + '</span>' +
578
634
  '</div>' +
579
635
  '<div class="flex items-center gap-2">' +
580
- '<span class="text-xs font-mono text-gray-400">' + c.schedule_type + ': ' + esc(c.schedule) + '</span>' +
636
+ '<span class="text-xs font-mono text-gray-400">' + esc(c.schedule_type) + ': ' + esc(c.schedule) + '</span>' +
581
637
  '<button class="btn-danger px-1.5 py-0.5 text-xs opacity-0 group-hover:opacity-100" aria-label="Delete task" onclick="deleteCron(\\'' + esc(c.id) + '\\')">x</button>' +
582
638
  '</div>' +
583
639
  '</div>' +
@@ -637,7 +693,13 @@ export function getDashboardHtml(token) {
637
693
 
638
694
  // --- Watchers ---
639
695
  async function loadWatchers() {
640
- let watchers; try { watchers = await api('/api/watchers'); } catch { return; }
696
+ let watchers;
697
+ try { watchers = await api('/api/watchers'); }
698
+ catch (err) {
699
+ const list = document.getElementById('watcher-list');
700
+ if (list) list.innerHTML = '<p class="text-red-400 text-sm text-center py-8">Failed to load watchers (' + esc(err && err.message ? err.message : String(err)) + ')</p>';
701
+ return;
702
+ }
641
703
  const list = document.getElementById('watcher-list');
642
704
 
643
705
  if (watchers.length === 0) {
@@ -675,7 +737,13 @@ export function getDashboardHtml(token) {
675
737
 
676
738
  // --- Costs ---
677
739
  async function loadCosts() {
678
- let costs; try { costs = await api('/api/costs'); } catch { return; }
740
+ let costs;
741
+ try { costs = await api('/api/costs'); }
742
+ catch (err) {
743
+ const chart = document.getElementById('cost-chart');
744
+ if (chart) chart.innerHTML = '<p class="text-red-400 text-sm text-center py-8">Failed to load costs (' + esc(err && err.message ? err.message : String(err)) + ')</p>';
745
+ return;
746
+ }
679
747
  const chart = document.getElementById('cost-chart');
680
748
 
681
749
  if (costs.length === 0) {
@@ -2,17 +2,37 @@
2
2
  // pathPattern is a regex string (or exact path). The dispatcher in server.ts
3
3
  // chooses the first matching entry and invokes its handler.
4
4
  import crypto from 'node:crypto';
5
- import { exec } from 'node:child_process';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { execFile } from 'node:child_process';
6
8
  import { promisify } from 'node:util';
7
9
  import { getDb } from '../db/index.js';
8
10
  import { logger } from '../util/logger.js';
9
- import { validateTabName, validateTabNameOrDefault } from '../config.js';
11
+ import { validateTabName, validateTabNameOrDefault, getConfig } from '../config.js';
10
12
  import { createTabRecord } from '../db/index.js';
11
13
  import { VERSION } from '../version.js';
12
14
  import { getDaemonPid } from '../cli/helpers.js';
13
15
  import { MESSAGE_LIMITS } from '../util/text.js';
14
16
  import { TabStore } from '../session/tab-store.js';
15
- const execAsync = promisify(exec);
17
+ import { PendingMessageStore } from '../session/pending-store.js';
18
+ import { expandHome } from '../util/paths.js';
19
+ const execFileAsync = promisify(execFile);
20
+ /**
21
+ * Check whether a workingDir resolves under an allowed root.
22
+ * Allowed roots: tabs.default.workingDir, projectScanPaths, $HOME.
23
+ * This mirrors the allowlist used by projects/manager.ts:createProject so the
24
+ * dashboard cannot create a tab pointing at /etc or another user's home.
25
+ */
26
+ function isAllowedWorkingDir(dir) {
27
+ const resolved = path.resolve(expandHome(dir));
28
+ const config = getConfig();
29
+ const home = os.homedir();
30
+ const roots = [config.tabs?.default?.workingDir, ...(config.projectScanPaths ?? []), home]
31
+ .filter((r) => typeof r === 'string' && r.length > 0)
32
+ .map((r) => path.resolve(expandHome(r)));
33
+ return roots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
34
+ }
35
+ const SAFE_NPM_PACKAGE = /^[@a-zA-Z0-9_/.-]+$/;
16
36
  function parseIntParam(value, def, max) {
17
37
  if (value === null)
18
38
  return def;
@@ -38,10 +58,10 @@ async function readBody(req, res) {
38
58
  return body;
39
59
  }
40
60
  function exactPath(p) {
41
- return path => path === p;
61
+ return (path) => path === p;
42
62
  }
43
63
  function regexPath(re) {
44
- return path => re.test(path);
64
+ return (path) => re.test(path);
45
65
  }
46
66
  export const ROUTES = [
47
67
  // SSE — never log a "broken pipe" write as a hard error
@@ -52,18 +72,27 @@ export const ROUTES = [
52
72
  res.writeHead(200, {
53
73
  'Content-Type': 'text/event-stream',
54
74
  'Cache-Control': 'no-cache',
55
- 'Connection': 'keep-alive',
75
+ Connection: 'keep-alive',
56
76
  });
57
77
  res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
78
+ // Skip pushes when nothing changed — without this every 2s tick wrote
79
+ // the full tab payload even when the daemon was idle.
80
+ let lastPayload = '';
58
81
  const interval = setInterval(() => {
59
82
  if (res.writableEnded)
60
83
  return;
61
84
  try {
62
- const tabs = TabStore.listAll().map(t => ({
63
- name: t.name, status: t.status, last_activity_at: t.lastActivityAt,
85
+ const tabs = TabStore.listAll().map((t) => ({
86
+ name: t.name,
87
+ status: t.status,
88
+ last_activity_at: t.lastActivityAt,
64
89
  }));
65
- const activeCount = tabs.filter(t => t.status === 'running').length;
66
- res.write(`data: ${JSON.stringify({ type: 'update', tabs, activeTabs: activeCount })}\n\n`);
90
+ const activeCount = tabs.filter((t) => t.status === 'running').length;
91
+ const payload = JSON.stringify({ type: 'update', tabs, activeTabs: activeCount });
92
+ if (payload === lastPayload)
93
+ return;
94
+ lastPayload = payload;
95
+ res.write(`data: ${payload}\n\n`);
67
96
  }
68
97
  catch (err) {
69
98
  logger.warn('Dashboard SSE tick failed:', err);
@@ -98,7 +127,7 @@ export const ROUTES = [
98
127
  json(res, { error: err }, 400);
99
128
  return;
100
129
  }
101
- getDb().prepare('INSERT INTO pending_messages (tab_name, message, type) VALUES (?, ?, ?)').run(tabName, parsed.message, 'user');
130
+ PendingMessageStore.enqueueUser(tabName, parsed.message, getDb());
102
131
  json(res, { success: true, tab: tabName });
103
132
  },
104
133
  },
@@ -127,8 +156,18 @@ export const ROUTES = [
127
156
  json(res, { error: err }, 400);
128
157
  return;
129
158
  }
159
+ if (parsed.workingDir && !isAllowedWorkingDir(parsed.workingDir)) {
160
+ json(res, {
161
+ error: 'workingDir must be under the workspace root, a project scan path, or your home directory',
162
+ }, 400);
163
+ return;
164
+ }
130
165
  try {
131
- createTabRecord(getDb(), { name: parsed.name, workingDir: parsed.workingDir, systemPrompt: parsed.systemPrompt });
166
+ createTabRecord(getDb(), {
167
+ name: parsed.name,
168
+ workingDir: parsed.workingDir,
169
+ systemPrompt: parsed.systemPrompt,
170
+ });
132
171
  json(res, { success: true, name: parsed.name });
133
172
  }
134
173
  catch (e) {
@@ -149,7 +188,7 @@ export const ROUTES = [
149
188
  // POST /api/tasks or /api/crons
150
189
  {
151
190
  method: 'POST',
152
- test: path => path === '/api/tasks' || path === '/api/crons',
191
+ test: (path) => path === '/api/tasks' || path === '/api/crons',
153
192
  handler: async ({ req, res }) => {
154
193
  const body = await readBody(req, res);
155
194
  if (body === null)
@@ -180,18 +219,31 @@ export const ROUTES = [
180
219
  return;
181
220
  }
182
221
  const id = crypto.randomUUID();
183
- getDb().prepare(`INSERT INTO tasks (id, name, schedule_type, schedule, tab_name, message, payload_type, enabled)
184
- VALUES (?, ?, ?, ?, ?, ?, 'agentTurn', 1)`).run(id, parsed.name, scheduleType, parsed.schedule, effectiveTab, parsed.message);
222
+ const { TaskStore } = await import('../tasks/store.js');
223
+ new TaskStore().add({
224
+ id,
225
+ name: parsed.name,
226
+ scheduleType: scheduleType,
227
+ schedule: parsed.schedule,
228
+ tabName: effectiveTab,
229
+ message: parsed.message,
230
+ payloadType: 'agentTurn',
231
+ enabled: true,
232
+ createdAt: new Date().toISOString(),
233
+ lastRunAt: null,
234
+ nextRunAt: null,
235
+ });
185
236
  json(res, { success: true, id });
186
237
  },
187
238
  },
188
239
  // DELETE /api/tasks/:id or /api/crons/:id
189
240
  {
190
241
  method: 'DELETE',
191
- test: path => /^\/api\/(tasks|crons)\/[^/]+$/.test(path),
192
- handler: ({ res, path }) => {
242
+ test: (path) => /^\/api\/(tasks|crons)\/[^/]+$/.test(path),
243
+ handler: async ({ res, path }) => {
193
244
  const taskId = decodeURIComponent(path.split('/')[3]);
194
- getDb().prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
245
+ const { TaskStore } = await import('../tasks/store.js');
246
+ new TaskStore().delete(taskId);
195
247
  json(res, { success: true });
196
248
  },
197
249
  },
@@ -199,19 +251,19 @@ export const ROUTES = [
199
251
  {
200
252
  method: 'GET',
201
253
  test: exactPath('/api/watchers'),
202
- handler: ({ res }) => {
203
- const limit = 500;
204
- const watchers = getDb().prepare('SELECT * FROM watchers ORDER BY created_at LIMIT ?').all(limit);
205
- json(res, watchers);
254
+ handler: async ({ res }) => {
255
+ const { WatcherStore } = await import('../watchers/store.js');
256
+ json(res, new WatcherStore().list());
206
257
  },
207
258
  },
208
259
  // DELETE /api/watchers/:id
209
260
  {
210
261
  method: 'DELETE',
211
262
  test: regexPath(/^\/api\/watchers\/[^/]+$/),
212
- handler: ({ res, path }) => {
263
+ handler: async ({ res, path }) => {
213
264
  const id = decodeURIComponent(path.split('/')[3]);
214
- getDb().prepare('DELETE FROM watchers WHERE id = ?').run(id);
265
+ const { WatcherStore } = await import('../watchers/store.js');
266
+ new WatcherStore().delete(id);
215
267
  json(res, { success: true });
216
268
  },
217
269
  },
@@ -235,7 +287,8 @@ export const ROUTES = [
235
287
  json(res, { error: 'Missing content' }, 400);
236
288
  return;
237
289
  }
238
- getDb().prepare('INSERT INTO memories (content, tab_name, source) VALUES (?, ?, ?)').run(parsed.content, parsed.tabName || null, 'tool');
290
+ const { MemoryStore } = await import('../session/memory-store.js');
291
+ MemoryStore.add(parsed.content, { tabName: parsed.tabName });
239
292
  json(res, { success: true });
240
293
  },
241
294
  },
@@ -243,9 +296,10 @@ export const ROUTES = [
243
296
  {
244
297
  method: 'DELETE',
245
298
  test: regexPath(/^\/api\/memories\/\d+$/),
246
- handler: ({ res, path }) => {
299
+ handler: async ({ res, path }) => {
247
300
  const id = path.split('/')[3];
248
- getDb().prepare('DELETE FROM memories WHERE id = ?').run(id);
301
+ const { MemoryStore } = await import('../session/memory-store.js');
302
+ MemoryStore.delete(id);
249
303
  json(res, { success: true });
250
304
  },
251
305
  },
@@ -256,7 +310,13 @@ export const ROUTES = [
256
310
  handler: async ({ res }) => {
257
311
  const { getConfig } = await import('../config.js');
258
312
  const generators = getConfig().mediaGenerators || [];
259
- json(res, { generators: generators.map(g => ({ provider: g.provider, model: g.model, configured: !!g.apiKey })) });
313
+ json(res, {
314
+ generators: generators.map((g) => ({
315
+ provider: g.provider,
316
+ model: g.model,
317
+ configured: !!g.apiKey,
318
+ })),
319
+ });
260
320
  },
261
321
  },
262
322
  // GET /api/channels/config
@@ -323,7 +383,7 @@ export const ROUTES = [
323
383
  test: exactPath('/api/status'),
324
384
  handler: ({ res }) => {
325
385
  const db = getDb();
326
- const activeTasks = db.prepare("SELECT COUNT(*) as c FROM tasks WHERE enabled = 1").get().c;
386
+ const activeTasks = db.prepare('SELECT COUNT(*) as c FROM tasks WHERE enabled = 1').get().c;
327
387
  json(res, {
328
388
  version: VERSION,
329
389
  daemonPid: getDaemonPid(),
@@ -341,12 +401,18 @@ export const ROUTES = [
341
401
  method: 'GET',
342
402
  test: exactPath('/api/tabs'),
343
403
  handler: ({ res }) => {
344
- const tabs = getDb().prepare(`
345
- SELECT t.*,
404
+ // Explicit column list — do NOT include session_id or system_prompt.
405
+ // session_id is the credential used by `claude --resume`; leaking it via
406
+ // /api/tabs (which any holder of the dashboard token can hit) would let
407
+ // an attacker resume any tab's claude session locally.
408
+ const tabs = getDb()
409
+ .prepare(`
410
+ SELECT t.id, t.name, t.status, t.working_dir, t.last_activity_at, t.created_at, t.pid,
346
411
  (SELECT COUNT(*) FROM messages WHERE tab_id = t.id) as message_count,
347
412
  (SELECT COALESCE(SUM(cost_usd), 0) FROM messages WHERE tab_id = t.id) as total_cost
348
413
  FROM tabs t ORDER BY t.last_activity_at DESC
349
- `).all();
414
+ `)
415
+ .all();
350
416
  json(res, tabs);
351
417
  },
352
418
  },
@@ -364,7 +430,9 @@ export const ROUTES = [
364
430
  return;
365
431
  }
366
432
  const db = getDb();
367
- const messages = db.prepare('SELECT role, content, cost_usd, tokens_in, tokens_out, created_at FROM messages WHERE tab_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?').all(tabId, limit, offset);
433
+ const messages = db
434
+ .prepare('SELECT role, content, cost_usd, tokens_in, tokens_out, created_at FROM messages WHERE tab_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?')
435
+ .all(tabId, limit, offset);
368
436
  const total = db.prepare('SELECT COUNT(*) as c FROM messages WHERE tab_id = ?').get(tabId).c;
369
437
  json(res, { messages: messages.reverse(), total, limit, offset });
370
438
  },
@@ -373,27 +441,28 @@ export const ROUTES = [
373
441
  {
374
442
  method: 'GET',
375
443
  test: exactPath('/api/memories'),
376
- handler: ({ res, url }) => {
444
+ handler: async ({ res, url }) => {
377
445
  const limit = parseIntParam(url.searchParams.get('limit'), 50, 200);
378
446
  const offset = parseIntParam(url.searchParams.get('offset'), 0, 100000);
379
447
  const q = url.searchParams.get('q') || '';
380
- const db = getDb();
381
- let memories, total;
382
- if (q) {
383
- memories = db.prepare('SELECT id, content, tab_name, source, created_at FROM memories WHERE content LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?').all(`%${q}%`, limit, offset);
384
- total = db.prepare('SELECT COUNT(*) as c FROM memories WHERE content LIKE ?').get(`%${q}%`).c;
385
- }
386
- else {
387
- memories = db.prepare('SELECT id, content, tab_name, source, created_at FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
388
- total = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
389
- }
448
+ const { MemoryStore } = await import('../session/memory-store.js');
449
+ const { memories: rows, total } = MemoryStore.list({ limit, offset, query: q || undefined });
450
+ // Dashboard HTML reads snake_case (tab_name, created_at) — map back here
451
+ // rather than reshape the store's typed return.
452
+ const memories = rows.map((m) => ({
453
+ id: m.id,
454
+ content: m.content,
455
+ tab_name: m.tabName,
456
+ source: m.source,
457
+ created_at: m.createdAt,
458
+ }));
390
459
  json(res, { memories, total, limit, offset });
391
460
  },
392
461
  },
393
462
  // GET /api/tasks or /api/crons
394
463
  {
395
464
  method: 'GET',
396
- test: path => path === '/api/tasks' || path === '/api/crons',
465
+ test: (path) => path === '/api/tasks' || path === '/api/crons',
397
466
  handler: ({ res, url }) => {
398
467
  const limit = parseIntParam(url.searchParams.get('limit'), 100, 500);
399
468
  const tasks = getDb().prepare('SELECT * FROM tasks ORDER BY created_at LIMIT ?').all(limit);
@@ -405,7 +474,8 @@ export const ROUTES = [
405
474
  method: 'GET',
406
475
  test: exactPath('/api/costs'),
407
476
  handler: ({ res }) => {
408
- const costs = getDb().prepare(`
477
+ const costs = getDb()
478
+ .prepare(`
409
479
  SELECT date(created_at) as day,
410
480
  SUM(cost_usd) as total_cost,
411
481
  COUNT(*) as message_count
@@ -414,7 +484,8 @@ export const ROUTES = [
414
484
  AND created_at > datetime('now', '-30 days')
415
485
  GROUP BY date(created_at)
416
486
  ORDER BY day
417
- `).all();
487
+ `)
488
+ .all();
418
489
  json(res, costs);
419
490
  },
420
491
  },
@@ -423,48 +494,37 @@ export const ROUTES = [
423
494
  method: 'GET',
424
495
  test: exactPath('/api/update/status'),
425
496
  handler: async ({ res }) => {
426
- async function checkPackage(name) {
427
- const pkg = { name };
497
+ async function npmViewLatest(name) {
428
498
  try {
429
- const { stdout } = await execAsync(`${name} --version`, { timeout: 10000 });
430
- pkg.installed = stdout.trim().replace(/^v/, '');
499
+ const { stdout } = await execFileAsync('npm', ['view', name, 'version'], {
500
+ timeout: 10000,
501
+ });
502
+ return stdout.trim();
431
503
  }
432
504
  catch {
433
- pkg.installed = null;
434
- }
435
- try {
436
- const { stdout } = await execAsync(`npm view ${name} version`, { timeout: 10000 });
437
- pkg.latest = stdout.trim();
505
+ return null;
438
506
  }
439
- catch {
440
- pkg.latest = null;
441
- }
442
- pkg.updateAvailable = !!(pkg.installed && pkg.latest && pkg.installed !== pkg.latest);
443
- return pkg;
444
507
  }
445
508
  const packages = await Promise.all([
446
509
  (async () => {
447
- const p = await checkPackage('beecork');
448
- p.installed = VERSION;
510
+ const p = {
511
+ name: 'beecork',
512
+ installed: VERSION,
513
+ latest: await npmViewLatest('beecork'),
514
+ };
449
515
  p.updateAvailable = !!(p.latest && p.installed !== p.latest);
450
516
  return p;
451
517
  })(),
452
518
  (async () => {
453
519
  const p = { name: '@anthropic-ai/claude-code' };
454
520
  try {
455
- const { stdout } = await execAsync('claude --version', { timeout: 10000 });
521
+ const { stdout } = await execFileAsync('claude', ['--version'], { timeout: 10000 });
456
522
  p.installed = stdout.trim().replace(/^.*?(\d+\.\d+\.\d+).*$/, '$1');
457
523
  }
458
524
  catch {
459
525
  p.installed = null;
460
526
  }
461
- try {
462
- const { stdout } = await execAsync('npm view @anthropic-ai/claude-code version', { timeout: 10000 });
463
- p.latest = stdout.trim();
464
- }
465
- catch {
466
- p.latest = null;
467
- }
527
+ p.latest = await npmViewLatest('@anthropic-ai/claude-code');
468
528
  p.updateAvailable = !!(p.installed && p.latest && p.installed !== p.latest);
469
529
  return p;
470
530
  })(),
@@ -478,17 +538,21 @@ export const ROUTES = [
478
538
  test: regexPath(/^\/api\/update\/[^/]+$/),
479
539
  handler: async ({ res, path }) => {
480
540
  const pkgName = decodeURIComponent(path.split('/')[3]);
481
- const allowedPackages = {
482
- 'beecork': 'npm install -g beecork@latest',
483
- '@anthropic-ai/claude-code': 'npm install -g @anthropic-ai/claude-code@latest',
484
- };
485
- const cmd = allowedPackages[pkgName];
486
- if (!cmd) {
541
+ const allowedPackages = new Set(['beecork', '@anthropic-ai/claude-code']);
542
+ if (!allowedPackages.has(pkgName)) {
487
543
  json(res, { error: `Package "${pkgName}" is not in the allowed update list.` }, 400);
488
544
  return;
489
545
  }
546
+ // Defense-in-depth: even though pkgName is allowlisted, validate against the
547
+ // same regex used elsewhere so a typo in the allowlist can't widen the surface.
548
+ if (!SAFE_NPM_PACKAGE.test(pkgName)) {
549
+ json(res, { error: `Invalid package name: ${pkgName}` }, 400);
550
+ return;
551
+ }
490
552
  try {
491
- const { stdout } = await execAsync(cmd, { timeout: 120000 });
553
+ const { stdout } = await execFileAsync('npm', ['install', '-g', `${pkgName}@latest`], {
554
+ timeout: 120000,
555
+ });
492
556
  json(res, { success: true, package: pkgName, output: stdout.trim() });
493
557
  }
494
558
  catch (err) {
@@ -502,7 +566,7 @@ export const ROUTES = [
502
566
  test: exactPath('/api/capabilities'),
503
567
  handler: async ({ res }) => {
504
568
  const { getAvailablePacks, isEnabled } = await import('../capabilities/index.js');
505
- const packs = getAvailablePacks().map(p => ({
569
+ const packs = getAvailablePacks().map((p) => ({
506
570
  ...p,
507
571
  enabled: isEnabled(p.id),
508
572
  mcpServer: { package: p.mcpServer.package },
@@ -64,7 +64,11 @@ export function startDashboardServer(port = 0) {
64
64
  if (path.startsWith('/api/')) {
65
65
  const authHeader = req.headers.authorization;
66
66
  const queryToken = url.searchParams.get('token');
67
- const cookieToken = req.headers.cookie?.split(';').map(c => c.trim()).find(c => c.startsWith('beecork_dash='))?.split('=')[1];
67
+ const cookieToken = req.headers.cookie
68
+ ?.split(';')
69
+ .map((c) => c.trim())
70
+ .find((c) => c.startsWith('beecork_dash='))
71
+ ?.split('=')[1];
68
72
  const providedToken = authHeader?.replace('Bearer ', '') || queryToken || cookieToken;
69
73
  if (!safeEqualToken(providedToken, authToken)) {
70
74
  json(res, { error: 'Unauthorized' }, 401);