beecork 1.4.11 → 1.6.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/admin.d.ts +10 -0
- package/dist/channels/admin.js +20 -0
- package/dist/channels/command-handler.d.ts +2 -10
- package/dist/channels/command-handler.js +90 -84
- package/dist/channels/discord.d.ts +4 -9
- package/dist/channels/discord.js +59 -42
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/loader.js +13 -4
- 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/send-helpers.d.ts +19 -0
- package/dist/channels/send-helpers.js +21 -0
- package/dist/channels/telegram.d.ts +21 -14
- package/dist/channels/telegram.js +214 -104
- package/dist/channels/types.d.ts +13 -38
- package/dist/channels/voice-state.d.ts +29 -0
- package/dist/channels/voice-state.js +45 -0
- package/dist/channels/webhook.d.ts +2 -5
- package/dist/channels/webhook.js +88 -29
- package/dist/channels/whatsapp.d.ts +9 -7
- package/dist/channels/whatsapp.js +141 -100
- 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 +85 -27
- 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.d.ts +5 -1
- package/dist/config.js +20 -22
- package/dist/daemon.js +113 -51
- package/dist/dashboard/html.js +100 -20
- package/dist/dashboard/routes.d.ts +17 -0
- package/dist/dashboard/routes.js +623 -0
- package/dist/dashboard/server.js +38 -489
- package/dist/db/connection.d.ts +29 -0
- package/dist/db/connection.js +37 -0
- package/dist/db/index.js +43 -11
- package/dist/db/migrations.js +114 -22
- package/dist/delegation/manager.js +10 -4
- package/dist/index.js +39 -59
- package/dist/knowledge/manager.js +26 -12
- package/dist/mcp/handlers.d.ts +37 -0
- package/dist/mcp/handlers.js +520 -0
- package/dist/mcp/server.js +44 -858
- package/dist/mcp/tool-definitions.d.ts +1225 -0
- package/dist/mcp/tool-definitions.js +412 -0
- 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.d.ts +2 -7
- package/dist/media/index.js +2 -2
- 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.d.ts +1 -1
- package/dist/observability/analytics.js +41 -16
- package/dist/projects/index.d.ts +3 -2
- package/dist/projects/index.js +2 -2
- package/dist/projects/manager.d.ts +1 -7
- package/dist/projects/manager.js +66 -42
- package/dist/projects/router.d.ts +12 -0
- package/dist/projects/router.js +98 -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 +21 -5
- package/dist/session/manager.js +166 -153
- 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 +3 -0
- package/dist/session/subprocess.js +54 -11
- package/dist/session/tab-store.d.ts +28 -0
- package/dist/session/tab-store.js +78 -0
- package/dist/tasks/scheduler.d.ts +13 -0
- package/dist/tasks/scheduler.js +97 -18
- package/dist/tasks/store.js +26 -12
- package/dist/timeline/logger.js +3 -1
- package/dist/timeline/query.js +15 -5
- package/dist/types.d.ts +49 -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 +2 -0
- package/dist/util/paths.js +16 -3
- package/dist/util/rate-limiter.js +8 -0
- package/dist/util/retry.js +1 -1
- package/dist/util/text.d.ts +21 -1
- package/dist/util/text.js +38 -8
- 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 +11 -5
- package/package.json +6 -1
- package/dist/session/tool-classifier.d.ts +0 -4
- package/dist/session/tool-classifier.js +0 -56
- package/dist/users/index.d.ts +0 -2
- package/dist/users/index.js +0 -1
- package/dist/users/service.d.ts +0 -17
- package/dist/users/service.js +0 -46
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tab FIFO queue for messages waiting on a busy subprocess.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from TabManager so the queue's "max size, enqueue, dequeue,
|
|
5
|
+
* peek-and-mutate-next, clear-on-stop" semantics are self-contained and
|
|
6
|
+
* unit-testable without spinning up a real subprocess.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_QUEUE_SIZE = 10;
|
|
9
|
+
export class MessageQueue {
|
|
10
|
+
queues = new Map();
|
|
11
|
+
/** Attempt to enqueue. Returns false if the per-tab queue is full. */
|
|
12
|
+
enqueue(tabName, msg) {
|
|
13
|
+
let queue = this.queues.get(tabName);
|
|
14
|
+
if (!queue) {
|
|
15
|
+
queue = [];
|
|
16
|
+
this.queues.set(tabName, queue);
|
|
17
|
+
}
|
|
18
|
+
if (queue.length >= MAX_QUEUE_SIZE)
|
|
19
|
+
return false;
|
|
20
|
+
queue.push(msg);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
/** Remove and return the next message. Drops the per-tab entry when empty. */
|
|
24
|
+
dequeue(tabName) {
|
|
25
|
+
const queue = this.queues.get(tabName);
|
|
26
|
+
if (!queue || queue.length === 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
const next = queue.shift();
|
|
29
|
+
if (queue.length === 0)
|
|
30
|
+
this.queues.delete(tabName);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
/** Peek at the next queued message without removing it (used to inject loop warning prefix). */
|
|
34
|
+
peek(tabName) {
|
|
35
|
+
return this.queues.get(tabName)?.[0];
|
|
36
|
+
}
|
|
37
|
+
size(tabName) {
|
|
38
|
+
return this.queues.get(tabName)?.length ?? 0;
|
|
39
|
+
}
|
|
40
|
+
/** Drain the queue for a tab, returning the dropped messages so callers can reject them. */
|
|
41
|
+
clear(tabName) {
|
|
42
|
+
const queue = this.queues.get(tabName) ?? [];
|
|
43
|
+
this.queues.delete(tabName);
|
|
44
|
+
return queue;
|
|
45
|
+
}
|
|
46
|
+
/** Drain every queue. Returns the dropped messages grouped by tab. */
|
|
47
|
+
clearAll() {
|
|
48
|
+
const all = Array.from(this.queues.values());
|
|
49
|
+
this.queues.clear();
|
|
50
|
+
return all;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -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;
|
|
@@ -12,6 +14,7 @@ export declare class ClaudeSubprocess {
|
|
|
12
14
|
private proc;
|
|
13
15
|
private buffer;
|
|
14
16
|
private killTimer;
|
|
17
|
+
private runtimeTimer;
|
|
15
18
|
readonly sessionId: string;
|
|
16
19
|
constructor(tabName: string, workingDir: string, config: BeecorkConfig, sessionId?: string, tabSystemPrompt?: string | null | undefined);
|
|
17
20
|
send(prompt: string, callbacks: SubprocessCallbacks, resume?: boolean): Promise<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;
|
|
@@ -31,6 +44,7 @@ export class ClaudeSubprocess {
|
|
|
31
44
|
proc = null;
|
|
32
45
|
buffer = '';
|
|
33
46
|
killTimer = null;
|
|
47
|
+
runtimeTimer = null;
|
|
34
48
|
sessionId;
|
|
35
49
|
constructor(tabName, workingDir, config, sessionId, tabSystemPrompt) {
|
|
36
50
|
this.tabName = tabName;
|
|
@@ -72,11 +86,19 @@ export class ClaudeSubprocess {
|
|
|
72
86
|
this.proc.stderr.on('data', (chunk) => {
|
|
73
87
|
const text = chunk.toString().trim();
|
|
74
88
|
if (text) {
|
|
75
|
-
|
|
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)}`);
|
|
76
94
|
}
|
|
77
95
|
});
|
|
78
96
|
this.proc.on('error', (err) => {
|
|
79
97
|
this.proc = null;
|
|
98
|
+
if (this.runtimeTimer) {
|
|
99
|
+
clearTimeout(this.runtimeTimer);
|
|
100
|
+
this.runtimeTimer = null;
|
|
101
|
+
}
|
|
80
102
|
callbacks.onError(err);
|
|
81
103
|
});
|
|
82
104
|
this.proc.on('exit', (code) => {
|
|
@@ -85,9 +107,25 @@ export class ClaudeSubprocess {
|
|
|
85
107
|
clearTimeout(this.killTimer);
|
|
86
108
|
this.killTimer = null;
|
|
87
109
|
}
|
|
110
|
+
if (this.runtimeTimer) {
|
|
111
|
+
clearTimeout(this.runtimeTimer);
|
|
112
|
+
this.runtimeTimer = null;
|
|
113
|
+
}
|
|
88
114
|
logger.info(`[${this.tabName}] Claude subprocess exited (code: ${code})`);
|
|
89
115
|
callbacks.onExit(code);
|
|
90
116
|
});
|
|
117
|
+
// Hard runtime cap so a wedged claude can't pin a tab forever.
|
|
118
|
+
// Default 30 minutes. Disable by setting maxRuntimeMs to 0.
|
|
119
|
+
const maxRuntimeMs = this.config.claudeCode.maxRuntimeMs ?? 30 * 60 * 1000;
|
|
120
|
+
if (maxRuntimeMs > 0) {
|
|
121
|
+
this.runtimeTimer = setTimeout(() => {
|
|
122
|
+
if (!this.proc)
|
|
123
|
+
return;
|
|
124
|
+
logger.warn(`[${this.tabName}] Subprocess exceeded maxRuntimeMs (${maxRuntimeMs}ms) — killing`);
|
|
125
|
+
callbacks.onError(new Error(`Subprocess timed out after ${Math.round(maxRuntimeMs / 1000)}s`));
|
|
126
|
+
this.kill();
|
|
127
|
+
}, maxRuntimeMs);
|
|
128
|
+
}
|
|
91
129
|
}
|
|
92
130
|
kill() {
|
|
93
131
|
if (!this.proc)
|
|
@@ -99,7 +137,9 @@ export class ClaudeSubprocess {
|
|
|
99
137
|
try {
|
|
100
138
|
proc.kill('SIGKILL');
|
|
101
139
|
}
|
|
102
|
-
catch {
|
|
140
|
+
catch {
|
|
141
|
+
/* already dead */
|
|
142
|
+
}
|
|
103
143
|
}, 5000);
|
|
104
144
|
}
|
|
105
145
|
get isRunning() {
|
|
@@ -111,14 +151,17 @@ export class ClaudeSubprocess {
|
|
|
111
151
|
buildArgs(prompt, resume) {
|
|
112
152
|
const args = [
|
|
113
153
|
'-p',
|
|
114
|
-
'--output-format',
|
|
154
|
+
'--output-format',
|
|
155
|
+
'stream-json',
|
|
115
156
|
'--verbose',
|
|
116
157
|
...this.config.claudeCode.defaultFlags,
|
|
117
158
|
];
|
|
118
|
-
// Only add MCP config if the file exists
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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());
|
|
122
165
|
}
|
|
123
166
|
// Inject Beecork system context so Claude knows about available tools
|
|
124
167
|
if (!resume) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { Tab, TabStatus } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* SQL helpers for the `tabs` table. Single place for any code that needs to
|
|
5
|
+
* read/write tab rows. TabManager owns subprocess lifecycle; TabStore owns
|
|
6
|
+
* the schema knowledge.
|
|
7
|
+
*
|
|
8
|
+
* All methods accept an optional `db` param so they're testable against an
|
|
9
|
+
* in-memory database. The no-arg form uses the singleton.
|
|
10
|
+
*/
|
|
11
|
+
export declare const TabStore: {
|
|
12
|
+
listAll(db?: Database.Database): Tab[];
|
|
13
|
+
findByName(name: string, db?: Database.Database): Tab | undefined;
|
|
14
|
+
getIdByName(name: string, db?: Database.Database): string | undefined;
|
|
15
|
+
countAll(db?: Database.Database): number;
|
|
16
|
+
countRunning(db?: Database.Database): number;
|
|
17
|
+
/** All tabs marked 'running' — used by daemon crash-recovery on startup. */
|
|
18
|
+
findRunning(db?: Database.Database): Tab[];
|
|
19
|
+
/** Most-recently-active tab — used by MCP to surface "current" context. */
|
|
20
|
+
mostRecent(db?: Database.Database): Tab | undefined;
|
|
21
|
+
setStatus(name: string, status: TabStatus, db?: Database.Database): void;
|
|
22
|
+
setIdleById(id: string, db?: Database.Database): void;
|
|
23
|
+
/** Used by MCP close-tab to nudge daemon recovery loop. */
|
|
24
|
+
markRunningAsStopped(name: string, db?: Database.Database): void;
|
|
25
|
+
setSystemPrompt(name: string, systemPrompt: string, db?: Database.Database): boolean;
|
|
26
|
+
/** Delete a tab and all of its messages atomically. Returns true if it existed. */
|
|
27
|
+
deleteWithMessages(name: string, db?: Database.Database): boolean;
|
|
28
|
+
};
|