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
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Polls `pending_messages` and dispatches each row by type.
3
+ *
4
+ * Replaces the inline `processPendingMessages` block that previously lived in
5
+ * TabManager. Two functional changes from that code:
6
+ *
7
+ * 1. Claim-before-dispatch (status='processing' set atomically before send).
8
+ * The original poll-every-5s code re-picked the same row on every tick
9
+ * while its dispatch was in flight — long Claude runs could re-fire the
10
+ * same prompt many times.
11
+ *
12
+ * 2. Branch on type. The original consumer only recognized 'notification' and
13
+ * fell through to "send as plain user prompt" for everything else, which
14
+ * meant `type='media'` rows were handed to Claude as raw JSON and never
15
+ * actually delivered to channels.
16
+ */
17
+ import type { TabManager } from './manager.js';
18
+ import type { ChannelRegistry } from '../channels/registry.js';
19
+ export type NotifyFn = ((text: string) => Promise<void>) | null;
20
+ export declare class PendingMessageDispatcher {
21
+ private tabManager;
22
+ private channels;
23
+ private onNotify;
24
+ private pollCount;
25
+ constructor(tabManager: TabManager, channels: ChannelRegistry, onNotify: NotifyFn);
26
+ /** Reset any rows stuck in 'processing' from a previous daemon run. */
27
+ recoverOnStart(): void;
28
+ /** Called on every daemon poll tick. */
29
+ tick(): void;
30
+ private dispatch;
31
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Polls `pending_messages` and dispatches each row by type.
3
+ *
4
+ * Replaces the inline `processPendingMessages` block that previously lived in
5
+ * TabManager. Two functional changes from that code:
6
+ *
7
+ * 1. Claim-before-dispatch (status='processing' set atomically before send).
8
+ * The original poll-every-5s code re-picked the same row on every tick
9
+ * while its dispatch was in flight — long Claude runs could re-fire the
10
+ * same prompt many times.
11
+ *
12
+ * 2. Branch on type. The original consumer only recognized 'notification' and
13
+ * fell through to "send as plain user prompt" for everything else, which
14
+ * meant `type='media'` rows were handed to Claude as raw JSON and never
15
+ * actually delivered to channels.
16
+ */
17
+ import { getDb } from '../db/index.js';
18
+ import { logger } from '../util/logger.js';
19
+ import { PendingMessageStore } from './pending-store.js';
20
+ const POLL_LIMIT = 50;
21
+ const CLEANUP_EVERY_N_TICKS = 100;
22
+ export class PendingMessageDispatcher {
23
+ tabManager;
24
+ channels;
25
+ onNotify;
26
+ pollCount = 0;
27
+ constructor(tabManager, channels, onNotify) {
28
+ this.tabManager = tabManager;
29
+ this.channels = channels;
30
+ this.onNotify = onNotify;
31
+ }
32
+ /** Reset any rows stuck in 'processing' from a previous daemon run. */
33
+ recoverOnStart() {
34
+ PendingMessageStore.recoverProcessing();
35
+ }
36
+ /** Called on every daemon poll tick. */
37
+ tick() {
38
+ const db = getDb();
39
+ this.pollCount++;
40
+ if (this.pollCount % CLEANUP_EVERY_N_TICKS === 0) {
41
+ db.prepare("DELETE FROM pending_messages WHERE (status = 'done' OR status = 'failed') AND created_at < datetime('now', '-1 day')").run();
42
+ }
43
+ const claimed = PendingMessageStore.claimBatch(POLL_LIMIT, db);
44
+ for (const row of claimed) {
45
+ // Fire-and-forget per row — the row is already marked 'processing' so
46
+ // the next tick won't double-dispatch. The .finally() handles the
47
+ // terminal transition.
48
+ void this.dispatch(row);
49
+ }
50
+ }
51
+ async dispatch(row) {
52
+ try {
53
+ switch (row.type) {
54
+ case 'notification':
55
+ await this.onNotify?.(row.message);
56
+ break;
57
+ case 'media': {
58
+ const payload = PendingMessageStore.parseMediaPayload(row.message);
59
+ if (!payload) {
60
+ logger.warn(`Pending media row ${row.id} has malformed payload, skipping`);
61
+ break;
62
+ }
63
+ const attachment = inferAttachmentType(payload.filePath, payload.caption);
64
+ await this.channels.broadcastMedia(attachment);
65
+ break;
66
+ }
67
+ case 'user':
68
+ case 'delegation':
69
+ case 'delegation_result':
70
+ case 'replay': {
71
+ if (!row.tabName) {
72
+ logger.warn(`Pending row ${row.id} (type=${row.type}) has no tab_name, skipping`);
73
+ break;
74
+ }
75
+ await this.tabManager.sendMessage(row.tabName, row.message);
76
+ break;
77
+ }
78
+ default: {
79
+ // Unknown discriminant — log and bail rather than fall through to
80
+ // "send as a plain prompt" (which is how H5 ended up shipping).
81
+ logger.warn(`Pending row ${row.id} has unknown type "${row.type}", marking failed`);
82
+ PendingMessageStore.markFailed(row.id);
83
+ return;
84
+ }
85
+ }
86
+ PendingMessageStore.markDone(row.id);
87
+ }
88
+ catch (err) {
89
+ logger.error(`Failed to process pending message ${row.id} (type=${row.type}):`, err);
90
+ await this.onNotify?.(`Pending message ${row.type === 'notification' ? '' : `for tab "${row.tabName}"`} failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
91
+ PendingMessageStore.markFailed(row.id);
92
+ }
93
+ }
94
+ }
95
+ /**
96
+ * Heuristic: derive a MediaAttachment from a file path so the channel's
97
+ * broadcastMedia knows whether to send as voice / image / video / document.
98
+ */
99
+ function inferAttachmentType(filePath, caption) {
100
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
101
+ let type = 'document';
102
+ let mimeType = 'application/octet-stream';
103
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) {
104
+ type = 'image';
105
+ mimeType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
106
+ }
107
+ else if (['mp4', 'mov', 'webm', 'mkv'].includes(ext)) {
108
+ type = 'video';
109
+ mimeType = `video/${ext === 'mov' ? 'quicktime' : ext}`;
110
+ }
111
+ else if (['ogg', 'opus', 'oga'].includes(ext)) {
112
+ type = 'voice';
113
+ mimeType = 'audio/ogg';
114
+ }
115
+ else if (['mp3', 'm4a', 'wav', 'flac', 'aac'].includes(ext)) {
116
+ type = 'audio';
117
+ mimeType = `audio/${ext}`;
118
+ }
119
+ return { type, mimeType, filePath, caption };
120
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Inter-process IPC store for the `pending_messages` table.
3
+ *
4
+ * Beecork uses this table as a shared queue between writers (MCP handlers,
5
+ * dashboard routes, watchers, delegations) and the daemon's poll loop. Before
6
+ * this module the writes were inline `INSERT INTO pending_messages` calls
7
+ * scattered across nine sites; the consumer in TabManager only branched on
8
+ * type='notification' which meant every other type fell through to "send as
9
+ * a plain user prompt" — including `type='media'` rows whose JSON envelope
10
+ * was being handed to Claude as text instead of being rendered.
11
+ *
12
+ * This store owns:
13
+ * - the typed envelope (`PendingMessageType` union, no more magic strings)
14
+ * - 3-state status (`pending` → `processing` → `done|failed`) so the 5s
15
+ * poll loop doesn't re-dispatch long-running rows
16
+ * - the `_notify` sentinel removal (notifications now use type alone,
17
+ * tab_name is nullable)
18
+ * - claim/recover semantics so a daemon crash mid-dispatch resets in-flight
19
+ * rows back to pending after a 30-minute timeout
20
+ */
21
+ import type Database from 'better-sqlite3';
22
+ export type PendingMessageType = 'user' | 'notification' | 'media' | 'delegation' | 'delegation_result' | 'replay';
23
+ export interface PendingRow {
24
+ id: number;
25
+ tabName: string | null;
26
+ message: string;
27
+ type: PendingMessageType;
28
+ status: 'pending' | 'processing' | 'done' | 'failed';
29
+ createdAt: string;
30
+ }
31
+ interface MediaPayload {
32
+ type: 'media';
33
+ filePath: string;
34
+ caption?: string;
35
+ }
36
+ export declare const PendingMessageStore: {
37
+ enqueueUser(tabName: string, message: string, db?: Database.Database): void;
38
+ enqueueNotification(message: string, db?: Database.Database): void;
39
+ enqueueMedia(tabName: string, payload: MediaPayload, db?: Database.Database): void;
40
+ enqueueDelegation(tabName: string, message: string, db?: Database.Database): void;
41
+ enqueueDelegationResult(tabName: string, message: string, db?: Database.Database): void;
42
+ enqueueReplay(tabName: string, message: string, db?: Database.Database): void;
43
+ /**
44
+ * Claim up to `limit` pending rows atomically. The rows are flipped to
45
+ * status='processing' inside a transaction so a second poll tick can't pick
46
+ * them up. Returns the claimed rows.
47
+ */
48
+ claimBatch(limit: number, db?: Database.Database): PendingRow[];
49
+ markDone(id: number, db?: Database.Database): void;
50
+ markFailed(id: number, db?: Database.Database): void;
51
+ /**
52
+ * Reset stuck `processing` rows back to `pending`. Run at daemon startup so
53
+ * a crash mid-dispatch doesn't leave rows permanently in-flight. The 30min
54
+ * window is well beyond the 30min subprocess runtime cap.
55
+ */
56
+ recoverProcessing(db?: Database.Database): number;
57
+ /** Parse a media row's message JSON. Returns null on malformed JSON. */
58
+ parseMediaPayload(message: string): MediaPayload | null;
59
+ };
60
+ export {};
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Inter-process IPC store for the `pending_messages` table.
3
+ *
4
+ * Beecork uses this table as a shared queue between writers (MCP handlers,
5
+ * dashboard routes, watchers, delegations) and the daemon's poll loop. Before
6
+ * this module the writes were inline `INSERT INTO pending_messages` calls
7
+ * scattered across nine sites; the consumer in TabManager only branched on
8
+ * type='notification' which meant every other type fell through to "send as
9
+ * a plain user prompt" — including `type='media'` rows whose JSON envelope
10
+ * was being handed to Claude as text instead of being rendered.
11
+ *
12
+ * This store owns:
13
+ * - the typed envelope (`PendingMessageType` union, no more magic strings)
14
+ * - 3-state status (`pending` → `processing` → `done|failed`) so the 5s
15
+ * poll loop doesn't re-dispatch long-running rows
16
+ * - the `_notify` sentinel removal (notifications now use type alone,
17
+ * tab_name is nullable)
18
+ * - claim/recover semantics so a daemon crash mid-dispatch resets in-flight
19
+ * rows back to pending after a 30-minute timeout
20
+ */
21
+ import { getDb } from '../db/index.js';
22
+ import { logger } from '../util/logger.js';
23
+ function rowToPending(r) {
24
+ return {
25
+ id: r.id,
26
+ tabName: r.tab_name,
27
+ message: r.message,
28
+ type: r.type || 'user',
29
+ status: r.status || 'pending',
30
+ createdAt: r.created_at,
31
+ };
32
+ }
33
+ const STALE_PROCESSING_MS = 30 * 60 * 1000; // 30 minutes
34
+ // Private INSERT helper — all five enqueue* methods were the same INSERT with
35
+ // different literals. Notification rows use '' for tab_name since the consumer
36
+ // branches on type rather than reading the tab.
37
+ function insertPending(db, tabName, message, type) {
38
+ db.prepare('INSERT INTO pending_messages (tab_name, message, type, status) VALUES (?, ?, ?, ?)').run(tabName, message, type, 'pending');
39
+ }
40
+ export const PendingMessageStore = {
41
+ enqueueUser(tabName, message, db = getDb()) {
42
+ insertPending(db, tabName, message, 'user');
43
+ },
44
+ enqueueNotification(message, db = getDb()) {
45
+ insertPending(db, '', message, 'notification');
46
+ },
47
+ enqueueMedia(tabName, payload, db = getDb()) {
48
+ insertPending(db, tabName, JSON.stringify(payload), 'media');
49
+ },
50
+ enqueueDelegation(tabName, message, db = getDb()) {
51
+ insertPending(db, tabName, message, 'delegation');
52
+ },
53
+ enqueueDelegationResult(tabName, message, db = getDb()) {
54
+ insertPending(db, tabName, message, 'delegation_result');
55
+ },
56
+ enqueueReplay(tabName, message, db = getDb()) {
57
+ insertPending(db, tabName, message, 'replay');
58
+ },
59
+ /**
60
+ * Claim up to `limit` pending rows atomically. The rows are flipped to
61
+ * status='processing' inside a transaction so a second poll tick can't pick
62
+ * them up. Returns the claimed rows.
63
+ */
64
+ claimBatch(limit, db = getDb()) {
65
+ const claim = db.transaction((max) => {
66
+ const rows = db
67
+ .prepare("SELECT id, tab_name, message, type, status, created_at FROM pending_messages WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?")
68
+ .all(max);
69
+ if (rows.length === 0)
70
+ return [];
71
+ const stmt = db.prepare("UPDATE pending_messages SET status = 'processing' WHERE id = ? AND status = 'pending'");
72
+ const claimed = [];
73
+ for (const r of rows) {
74
+ const result = stmt.run(r.id);
75
+ if (result.changes > 0)
76
+ claimed.push(rowToPending(r));
77
+ }
78
+ return claimed;
79
+ });
80
+ return claim(limit);
81
+ },
82
+ markDone(id, db = getDb()) {
83
+ db.prepare("UPDATE pending_messages SET status = 'done', processed = 1 WHERE id = ?").run(id);
84
+ },
85
+ markFailed(id, db = getDb()) {
86
+ // 'failed' rows are kept for diagnostics until the periodic cleanup sweeps
87
+ // them. They are NOT re-dispatched — preserves the original "no infinite
88
+ // retries" policy while keeping the row visible for postmortem queries.
89
+ db.prepare("UPDATE pending_messages SET status = 'failed', processed = 1 WHERE id = ?").run(id);
90
+ },
91
+ /**
92
+ * Reset stuck `processing` rows back to `pending`. Run at daemon startup so
93
+ * a crash mid-dispatch doesn't leave rows permanently in-flight. The 30min
94
+ * window is well beyond the 30min subprocess runtime cap.
95
+ */
96
+ recoverProcessing(db = getDb()) {
97
+ const result = db
98
+ .prepare(`UPDATE pending_messages SET status = 'pending'
99
+ WHERE status = 'processing' AND created_at < datetime('now', '-' || ? || ' seconds')`)
100
+ .run(Math.floor(STALE_PROCESSING_MS / 1000));
101
+ if (result.changes > 0) {
102
+ logger.warn(`Recovered ${result.changes} stuck pending_messages rows from 'processing' state`);
103
+ }
104
+ return result.changes;
105
+ },
106
+ /** Parse a media row's message JSON. Returns null on malformed JSON. */
107
+ parseMediaPayload(message) {
108
+ try {
109
+ const parsed = JSON.parse(message);
110
+ if (parsed?.type === 'media' && typeof parsed.filePath === 'string')
111
+ return parsed;
112
+ return null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ },
118
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Detection + recovery for "Claude Code session not found / expired" errors.
3
+ *
4
+ * Extracted from TabManager.executeMessage so the two responsibilities
5
+ * (recognize the symptom, build the recovery prompt) live in one focused
6
+ * module instead of tangled inline with the subprocess callbacks.
7
+ */
8
+ import type Database from 'better-sqlite3';
9
+ import type { StreamResult } from '../types.js';
10
+ import type { SendResult } from './manager.js';
11
+ export interface StaleSessionRecovery {
12
+ newSessionId: string;
13
+ /** A prompt that wraps the original enrichedPrompt with the last few messages
14
+ * for context, since Claude Code lost the session and won't have any. */
15
+ contextPrompt: string;
16
+ }
17
+ export declare const StaleSessionDetector: {
18
+ /**
19
+ * Was this result an "I can't resume that session" failure?
20
+ *
21
+ * Covers both the legacy text-based error and the modern
22
+ * `error_during_execution` event shape.
23
+ */
24
+ isStale(result: SendResult, resultEvent: StreamResult | null, shouldResume: boolean, retryDepth: number): boolean;
25
+ /**
26
+ * Build a recovery prompt seeded with the tab's last 5 messages as context,
27
+ * and allocate a fresh session ID. Caller is responsible for updating the
28
+ * tab's session_id in the DB before retrying.
29
+ */
30
+ buildRecovery(tabId: string, enrichedPrompt: string, db: Database.Database): StaleSessionRecovery;
31
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Detection + recovery for "Claude Code session not found / expired" errors.
3
+ *
4
+ * Extracted from TabManager.executeMessage so the two responsibilities
5
+ * (recognize the symptom, build the recovery prompt) live in one focused
6
+ * module instead of tangled inline with the subprocess callbacks.
7
+ */
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ export const StaleSessionDetector = {
10
+ /**
11
+ * Was this result an "I can't resume that session" failure?
12
+ *
13
+ * Covers both the legacy text-based error and the modern
14
+ * `error_during_execution` event shape.
15
+ */
16
+ isStale(result, resultEvent, shouldResume, retryDepth) {
17
+ if (!result.error || !shouldResume || retryDepth !== 0)
18
+ return false;
19
+ if (/session (not found|expired|invalid)/i.test(result.text))
20
+ return true;
21
+ if (resultEvent?.subtype === 'error_during_execution' &&
22
+ resultEvent.errors?.some((e) => /no conversation found|session.*not found|session.*expired|session.*invalid/i.test(e))) {
23
+ return true;
24
+ }
25
+ return false;
26
+ },
27
+ /**
28
+ * Build a recovery prompt seeded with the tab's last 5 messages as context,
29
+ * and allocate a fresh session ID. Caller is responsible for updating the
30
+ * tab's session_id in the DB before retrying.
31
+ */
32
+ buildRecovery(tabId, enrichedPrompt, db) {
33
+ const recentMsgs = db
34
+ .prepare("SELECT role, content FROM messages WHERE tab_id = ? AND content != '' ORDER BY created_at DESC LIMIT 5")
35
+ .all(tabId);
36
+ const context = recentMsgs
37
+ .reverse()
38
+ .map((m) => `${m.role}: ${m.content.slice(0, 200)}`)
39
+ .join('\n');
40
+ const contextPrompt = context
41
+ ? `[Previous conversation context:\n${context}\n]\n\n${enrichedPrompt}`
42
+ : enrichedPrompt;
43
+ return { newSessionId: uuidv4(), contextPrompt };
44
+ },
45
+ };
@@ -1,4 +1,6 @@
1
1
  import type { BeecorkConfig, StreamEvent } from '../types.js';
2
+ /** Test-only — reset the existsSync cache between test runs that toggle the mock. */
3
+ export declare function _resetMcpConfigExistsCacheForTests(): void;
2
4
  export interface SubprocessCallbacks {
3
5
  onEvent: (event: StreamEvent) => void;
4
6
  onExit: (code: number | null) => void;
@@ -3,14 +3,27 @@ import fs from 'node:fs';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
  import { logger } from '../util/logger.js';
5
5
  import { getMcpConfigPath } from '../util/paths.js';
6
+ // Cache the MCP-config existence check. The file is created at setup time and
7
+ // stable for the daemon's lifetime; we don't need to stat() it per spawn.
8
+ let mcpConfigExistsCache = null;
9
+ function mcpConfigExists() {
10
+ if (mcpConfigExistsCache !== null)
11
+ return mcpConfigExistsCache;
12
+ mcpConfigExistsCache = fs.existsSync(getMcpConfigPath());
13
+ return mcpConfigExistsCache;
14
+ }
15
+ /** Test-only — reset the existsSync cache between test runs that toggle the mock. */
16
+ export function _resetMcpConfigExistsCacheForTests() {
17
+ mcpConfigExistsCache = null;
18
+ }
6
19
  const BEECORK_SYSTEM_PROMPT = `You are running inside Beecork, an always-on infrastructure for Claude Code.
7
20
 
8
21
  You have these special MCP tools available:
9
22
  - beecork_remember: Store important facts for future sessions (preferences, server addresses, decisions, outcomes)
10
23
  - beecork_recall: Search stored memories — ALWAYS call this at the start of complex tasks
11
- - beecork_cron_create: Schedule recurring tasks (types: "at" for one-time, "every" for interval like "30m", "cron" for expressions like "0 9 * * 1")
12
- - beecork_cron_list: List scheduled tasks
13
- - beecork_cron_delete: Remove a scheduled task
24
+ - beecork_task_create: Schedule recurring tasks (types: "at" for one-time, "every" for interval like "30m", "cron" for expressions like "0 9 * * 1")
25
+ - beecork_task_list: List scheduled tasks
26
+ - beecork_task_delete: Remove a scheduled task
14
27
  - beecork_tab_create: Create a new virtual tab for parallel work
15
28
  - beecork_tab_list: List all tabs
16
29
  - beecork_send_message: Send a message to another tab
@@ -21,7 +34,7 @@ Guidelines:
21
34
  - You are running unattended. Be thorough and complete tasks fully.
22
35
  - Always call beecork_recall at the start of any task to check relevant memories.
23
36
  - Always call beecork_remember when you learn something important.
24
- - When asked for recurring tasks, use beecork_cron_create.
37
+ - When asked for recurring tasks, use beecork_task_create.
25
38
  - Use beecork_notify for progress updates during long tasks.`;
26
39
  export class ClaudeSubprocess {
27
40
  tabName;
@@ -73,7 +86,11 @@ export class ClaudeSubprocess {
73
86
  this.proc.stderr.on('data', (chunk) => {
74
87
  const text = chunk.toString().trim();
75
88
  if (text) {
76
- logger.debug(`[${this.tabName}] stderr: ${text.slice(0, 500)}`);
89
+ // claude prints auth failures, rate limits, license issues, and other
90
+ // operational errors to stderr. Surface at warn so they reach daemon.log
91
+ // at the default level — debugging "why did claude exit?" otherwise
92
+ // requires recompiling with a different log level.
93
+ logger.warn(`[${this.tabName}] claude stderr: ${text.slice(0, 500)}`);
77
94
  }
78
95
  });
79
96
  this.proc.on('error', (err) => {
@@ -120,7 +137,9 @@ export class ClaudeSubprocess {
120
137
  try {
121
138
  proc.kill('SIGKILL');
122
139
  }
123
- catch { /* already dead */ }
140
+ catch {
141
+ /* already dead */
142
+ }
124
143
  }, 5000);
125
144
  }
126
145
  get isRunning() {
@@ -132,14 +151,17 @@ export class ClaudeSubprocess {
132
151
  buildArgs(prompt, resume) {
133
152
  const args = [
134
153
  '-p',
135
- '--output-format', 'stream-json',
154
+ '--output-format',
155
+ 'stream-json',
136
156
  '--verbose',
137
157
  ...this.config.claudeCode.defaultFlags,
138
158
  ];
139
- // Only add MCP config if the file exists
140
- const mcpConfig = getMcpConfigPath();
141
- if (fs.existsSync(mcpConfig)) {
142
- args.push('--mcp-config', mcpConfig);
159
+ // Only add MCP config if the file exists. existsSync result is cached at
160
+ // module scope — the path is stable for the daemon's lifetime, so doing
161
+ // this once-per-process saves a syscall per claude spawn (every message,
162
+ // every task firing, every watcher trigger).
163
+ if (mcpConfigExists()) {
164
+ args.push('--mcp-config', getMcpConfigPath());
143
165
  }
144
166
  // Inject Beecork system context so Claude knows about available tools
145
167
  if (!resume) {
@@ -48,8 +48,7 @@ export const TabStore = {
48
48
  return row ? rowToTab(row) : undefined;
49
49
  },
50
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);
51
+ db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?').run(status, new Date().toISOString(), name);
53
52
  },
54
53
  setIdleById(id, db = getDb()) {
55
54
  db.prepare("UPDATE tabs SET status = 'idle', pid = NULL WHERE id = ?").run(id);
@@ -59,7 +58,9 @@ export const TabStore = {
59
58
  db.prepare("UPDATE tabs SET status = 'stopped', pid = NULL WHERE name = ? AND status = 'running'").run(name);
60
59
  },
61
60
  setSystemPrompt(name, systemPrompt, db = getDb()) {
62
- const result = db.prepare('UPDATE tabs SET system_prompt = ? WHERE name = ?').run(systemPrompt, name);
61
+ const result = db
62
+ .prepare('UPDATE tabs SET system_prompt = ? WHERE name = ?')
63
+ .run(systemPrompt, name);
63
64
  return result.changes > 0;
64
65
  },
65
66
  /** Delete a tab and all of its messages atomically. Returns true if it existed. */
@@ -6,6 +6,7 @@ export declare class TaskScheduler {
6
6
  private onNotify;
7
7
  private nextRunAt;
8
8
  private running;
9
+ private failureCounts;
9
10
  private stopping;
10
11
  private store;
11
12
  constructor(tabManager: TabManager, onNotify: NotifyCallback | null);
@@ -24,6 +25,12 @@ export declare class TaskScheduler {
24
25
  /** Compute next run time in ms epoch, given a "from" anchor. Returns null if invalid. */
25
26
  private computeNextRun;
26
27
  private fireJob;
28
+ /**
29
+ * Track consecutive failures per task. After MAX_CONSECUTIVE_FAILURES the
30
+ * task is auto-disabled in the DB and a single "auto-disabled" notification
31
+ * is sent. Counter resets on the next successful fire.
32
+ */
33
+ private recordFailure;
27
34
  private handleSystemEvent;
28
35
  }
29
36
  /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
@@ -7,6 +7,12 @@ import { TaskStore } from './store.js';
7
7
  import { getCronReloadSignalPath, getLogsDir } from '../util/paths.js';
8
8
  import { logger } from '../util/logger.js';
9
9
  export const execAsync = promisify(exec);
10
+ /**
11
+ * Disable a task after this many consecutive failures so a misconfigured
12
+ * cron doesn't fire (and notify) every interval indefinitely. Reset on
13
+ * success.
14
+ */
15
+ const MAX_CONSECUTIVE_FAILURES = 5;
10
16
  export class TaskScheduler {
11
17
  tabManager;
12
18
  onNotify;
@@ -14,6 +20,8 @@ export class TaskScheduler {
14
20
  nextRunAt = new Map();
15
21
  // taskIds with an in-flight fireJob
16
22
  running = new Set();
23
+ // taskId -> consecutive failure count, used for auto-disable
24
+ failureCounts = new Map();
17
25
  stopping = false;
18
26
  store = new TaskStore();
19
27
  constructor(tabManager, onNotify) {
@@ -69,7 +77,9 @@ export class TaskScheduler {
69
77
  try {
70
78
  fs.unlinkSync(signalPath);
71
79
  }
72
- catch { /* race condition, ok */ }
80
+ catch {
81
+ /* race condition, ok */
82
+ }
73
83
  logger.info('Tasks: reload signal detected, reloading schedules');
74
84
  this.loadAndSchedule();
75
85
  }
@@ -124,11 +134,15 @@ export class TaskScheduler {
124
134
  try {
125
135
  switch (job.scheduleType) {
126
136
  case 'cron':
127
- return CronExpressionParser.parse(job.schedule, { currentDate: new Date(fromMs) }).next().getTime();
137
+ return CronExpressionParser.parse(job.schedule, { currentDate: new Date(fromMs) })
138
+ .next()
139
+ .getTime();
128
140
  case 'every': {
129
141
  const expr = intervalToCron(job.schedule);
130
142
  if (expr) {
131
- return CronExpressionParser.parse(expr, { currentDate: new Date(fromMs) }).next().getTime();
143
+ return CronExpressionParser.parse(expr, { currentDate: new Date(fromMs) })
144
+ .next()
145
+ .getTime();
132
146
  }
133
147
  const ms = intervalToMs(job.schedule);
134
148
  return ms ? fromMs + ms : null;
@@ -169,6 +183,12 @@ export class TaskScheduler {
169
183
  const status = result.error ? 'ERROR' : 'SUCCESS';
170
184
  // Log result (status reflects subprocess exit / is_error, not just completion)
171
185
  await fs.promises.appendFile(logFile, `[${new Date().toISOString()}] ${status}: ${firstLine}\n`);
186
+ if (result.error) {
187
+ this.recordFailure(job);
188
+ }
189
+ else {
190
+ this.failureCounts.delete(job.id);
191
+ }
172
192
  // Notify (separate try/catch -- notification failure shouldn't be reported as job failure)
173
193
  try {
174
194
  if (this.onNotify) {
@@ -188,10 +208,30 @@ export class TaskScheduler {
188
208
  const errMsg = err instanceof Error ? err.message : String(err);
189
209
  logger.error(`Task "${job.name}" failed:`, err);
190
210
  await fs.promises.appendFile(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
211
+ this.recordFailure(job);
191
212
  try {
192
213
  await this.onNotify?.(`[${job.name}] Failed -- ${errMsg}`);
193
214
  }
194
- catch { /* notification best-effort */ }
215
+ catch {
216
+ /* notification best-effort */
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * Track consecutive failures per task. After MAX_CONSECUTIVE_FAILURES the
222
+ * task is auto-disabled in the DB and a single "auto-disabled" notification
223
+ * is sent. Counter resets on the next successful fire.
224
+ */
225
+ recordFailure(job) {
226
+ const count = (this.failureCounts.get(job.id) ?? 0) + 1;
227
+ this.failureCounts.set(job.id, count);
228
+ if (count >= MAX_CONSECUTIVE_FAILURES) {
229
+ logger.warn(`Task "${job.name}" hit ${count} consecutive failures — auto-disabling`);
230
+ this.store.update(job.id, { enabled: false });
231
+ this.nextRunAt.delete(job.id);
232
+ this.failureCounts.delete(job.id);
233
+ // Notify once. Subsequent enable + re-failure will trigger again.
234
+ this.onNotify?.(`Task "${job.name}" auto-disabled after ${count} consecutive failures. Re-enable from the dashboard or via beecork_task_update.`).catch(() => { });
195
235
  }
196
236
  }
197
237
  async handleSystemEvent(job) {
@@ -210,7 +250,7 @@ export class TaskScheduler {
210
250
  /** Parse a "1w2d3h45m"-style interval into its parts. Returns null on invalid input. */
211
251
  function parseInterval(interval) {
212
252
  const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
213
- if (!match || match.slice(1).every(g => g === undefined))
253
+ if (!match || match.slice(1).every((g) => g === undefined))
214
254
  return null;
215
255
  return {
216
256
  weeks: parseInt(match[1] || '0', 10),
@@ -225,7 +265,7 @@ export function intervalToMs(interval) {
225
265
  if (!parts)
226
266
  return null;
227
267
  const { weeks, days, hours, mins } = parts;
228
- const totalMs = ((weeks * 7 * 24 * 60) + (days * 24 * 60) + (hours * 60) + mins) * 60 * 1000;
268
+ const totalMs = (weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + mins) * 60 * 1000;
229
269
  return totalMs > 0 ? totalMs : null;
230
270
  }
231
271
  /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */