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.
- package/dist/capabilities/index.d.ts +1 -1
- package/dist/capabilities/index.js +1 -1
- package/dist/capabilities/manager.js +13 -9
- package/dist/capabilities/packs.js +3 -1
- package/dist/channels/command-handler.js +46 -14
- package/dist/channels/discord.d.ts +3 -6
- package/dist/channels/discord.js +40 -23
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/loader.js +13 -3
- package/dist/channels/pipeline.js +14 -5
- package/dist/channels/registry.d.ts +17 -1
- package/dist/channels/registry.js +33 -4
- package/dist/channels/telegram.d.ts +20 -5
- package/dist/channels/telegram.js +177 -42
- package/dist/channels/types.d.ts +11 -28
- package/dist/channels/voice-state.js +3 -1
- package/dist/channels/webhook.d.ts +1 -4
- package/dist/channels/webhook.js +26 -11
- package/dist/channels/whatsapp.d.ts +8 -4
- package/dist/channels/whatsapp.js +65 -29
- package/dist/cli/capabilities.js +4 -4
- package/dist/cli/channel.js +16 -6
- package/dist/cli/commands.js +12 -9
- package/dist/cli/doctor.js +80 -25
- package/dist/cli/handoff.d.ts +7 -14
- package/dist/cli/handoff.js +9 -44
- package/dist/cli/mcp.js +5 -5
- package/dist/cli/media.js +21 -8
- package/dist/cli/setup.js +9 -8
- package/dist/cli/store.js +29 -12
- package/dist/config.js +5 -10
- package/dist/daemon.js +88 -38
- package/dist/dashboard/html.js +80 -12
- package/dist/dashboard/routes.js +143 -79
- package/dist/dashboard/server.js +5 -1
- package/dist/db/connection.d.ts +29 -0
- package/dist/db/connection.js +37 -0
- package/dist/db/index.js +30 -12
- package/dist/db/migrations.js +84 -28
- package/dist/delegation/manager.js +10 -4
- package/dist/index.js +39 -59
- package/dist/knowledge/manager.js +26 -12
- package/dist/mcp/handlers.js +126 -57
- package/dist/mcp/server.js +20 -10
- package/dist/mcp/tool-definitions.js +68 -20
- package/dist/mcp/validate.d.ts +23 -0
- package/dist/mcp/validate.js +65 -0
- package/dist/media/factory.js +18 -14
- package/dist/media/generators/dall-e.js +2 -2
- package/dist/media/generators/kling.js +4 -4
- package/dist/media/generators/lyria.js +1 -1
- package/dist/media/generators/nano-banana.d.ts +1 -1
- package/dist/media/generators/nano-banana.js +2 -2
- package/dist/media/generators/poll-util.js +4 -4
- package/dist/media/generators/recraft.js +3 -3
- package/dist/media/generators/runway.js +4 -4
- package/dist/media/generators/stable-diffusion.js +2 -2
- package/dist/media/generators/veo.js +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/store.d.ts +7 -0
- package/dist/media/store.js +18 -4
- package/dist/media/types.d.ts +22 -0
- package/dist/notifications/index.d.ts +2 -4
- package/dist/notifications/index.js +6 -19
- package/dist/notifications/ntfy.js +3 -3
- package/dist/observability/analytics.js +35 -13
- package/dist/projects/index.d.ts +1 -1
- package/dist/projects/index.js +1 -1
- package/dist/projects/manager.d.ts +0 -4
- package/dist/projects/manager.js +51 -28
- package/dist/projects/router.d.ts +2 -0
- package/dist/projects/router.js +70 -45
- package/dist/service/install.js +15 -5
- package/dist/service/windows.js +1 -1
- package/dist/session/budget-guard.d.ts +20 -0
- package/dist/session/budget-guard.js +31 -0
- package/dist/session/circuit-breaker.d.ts +5 -3
- package/dist/session/circuit-breaker.js +45 -20
- package/dist/session/context-compactor.d.ts +32 -0
- package/dist/session/context-compactor.js +45 -0
- package/dist/session/context-monitor.js +2 -2
- package/dist/session/handoff.d.ts +21 -0
- package/dist/session/handoff.js +50 -0
- package/dist/session/manager.d.ts +17 -5
- package/dist/session/manager.js +153 -146
- package/dist/session/memory-store.d.ts +29 -0
- package/dist/session/memory-store.js +45 -0
- package/dist/session/message-queue.d.ts +28 -0
- package/dist/session/message-queue.js +52 -0
- package/dist/session/pending-dispatcher.d.ts +31 -0
- package/dist/session/pending-dispatcher.js +120 -0
- package/dist/session/pending-store.d.ts +60 -0
- package/dist/session/pending-store.js +118 -0
- package/dist/session/stale-session.d.ts +31 -0
- package/dist/session/stale-session.js +45 -0
- package/dist/session/subprocess.d.ts +2 -0
- package/dist/session/subprocess.js +33 -11
- package/dist/session/tab-store.js +4 -3
- package/dist/tasks/scheduler.d.ts +7 -0
- package/dist/tasks/scheduler.js +46 -6
- package/dist/tasks/store.js +20 -6
- package/dist/timeline/logger.js +3 -1
- package/dist/timeline/query.js +9 -3
- package/dist/types.d.ts +34 -9
- package/dist/util/auto-heal.js +15 -5
- package/dist/util/install-info.js +3 -1
- package/dist/util/logger.d.ts +1 -1
- package/dist/util/logger.js +63 -24
- package/dist/util/paths.d.ts +1 -0
- package/dist/util/paths.js +12 -2
- package/dist/util/retry.js +1 -1
- package/dist/util/text.js +13 -7
- package/dist/voice/index.js +5 -1
- package/dist/voice/stt.js +14 -6
- package/dist/voice/tts.js +1 -1
- package/dist/watchers/scheduler.js +9 -2
- package/package.json +18 -13
- package/dist/session/tool-classifier.d.ts +0 -4
- 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
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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',
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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 */
|
package/dist/tasks/scheduler.js
CHANGED
|
@@ -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 {
|
|
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) })
|
|
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) })
|
|
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 {
|
|
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 = (
|
|
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 */
|