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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BeecorkConfig, Tab } from '../types.js';
|
|
2
|
+
export type { StreamResult } from '../types.js';
|
|
2
3
|
export interface SendResult {
|
|
3
4
|
text: string;
|
|
4
5
|
costUsd: number;
|
|
@@ -7,11 +8,25 @@ export interface SendResult {
|
|
|
7
8
|
error: boolean;
|
|
8
9
|
}
|
|
9
10
|
export type NotifyCallback = (text: string) => Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* TabManager orchestrates per-tab Claude subprocess lifecycles. Heavy lifting
|
|
13
|
+
* (queueing, budget enforcement, stale-session recovery, context compaction,
|
|
14
|
+
* pending-message dispatch) is delegated to collaborators in this directory
|
|
15
|
+
* so this class stays focused on:
|
|
16
|
+
*
|
|
17
|
+
* - tab CRUD (ensureTab, listTabs, stopTab, closeTab, stopAll)
|
|
18
|
+
* - subprocess orchestration (executeMessage)
|
|
19
|
+
* - wiring the collaborators together
|
|
20
|
+
*
|
|
21
|
+
* The split happened in the 2026-05-15 audit fix; see the audit file for the
|
|
22
|
+
* rationale. Before the split this file was 467 lines and 10 responsibilities.
|
|
23
|
+
*/
|
|
10
24
|
export declare class TabManager {
|
|
11
25
|
private config;
|
|
12
26
|
private subprocesses;
|
|
13
|
-
private
|
|
14
|
-
private
|
|
27
|
+
private queue;
|
|
28
|
+
private budget;
|
|
29
|
+
private tabCostCache;
|
|
15
30
|
private onNotify;
|
|
16
31
|
constructor(config: BeecorkConfig);
|
|
17
32
|
/** Set a callback for sending notifications (e.g., via Telegram) */
|
|
@@ -30,13 +45,14 @@ export declare class TabManager {
|
|
|
30
45
|
listTabs(): Tab[];
|
|
31
46
|
/** Get a specific tab */
|
|
32
47
|
getTab(tabName: string): Tab | undefined;
|
|
33
|
-
private queryTab;
|
|
34
48
|
/** Stop a tab's running subprocess */
|
|
35
49
|
stopTab(tabName: string): void;
|
|
50
|
+
/** Update a tab's system_prompt. Returns true if the tab existed. */
|
|
51
|
+
setSystemPrompt(tabName: string, systemPrompt: string): boolean;
|
|
52
|
+
/** Close a tab — stop subprocess and delete its rows. Returns true if the tab existed. */
|
|
53
|
+
closeTab(tabName: string): boolean;
|
|
36
54
|
/** Stop all running subprocesses (clean shutdown) */
|
|
37
55
|
stopAll(): void;
|
|
38
|
-
/** Process pending messages from MCP server IPC */
|
|
39
|
-
processPendingMessages(): void;
|
|
40
56
|
private executeMessage;
|
|
41
57
|
private processNextInQueue;
|
|
42
58
|
private updateTabStatus;
|
package/dist/session/manager.js
CHANGED
|
@@ -2,34 +2,46 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
2
2
|
import { ClaudeSubprocess } from './subprocess.js';
|
|
3
3
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
4
4
|
import { ContextMonitor } from './context-monitor.js';
|
|
5
|
+
import { TabStore } from './tab-store.js';
|
|
6
|
+
import { MessageQueue } from './message-queue.js';
|
|
7
|
+
import { BudgetGuard } from './budget-guard.js';
|
|
8
|
+
import { StaleSessionDetector } from './stale-session.js';
|
|
9
|
+
import { ContextCompactor } from './context-compactor.js';
|
|
10
|
+
import { PendingMessageStore } from './pending-store.js';
|
|
11
|
+
import { completeDelegation } from '../delegation/manager.js';
|
|
5
12
|
import { getDb } from '../db/index.js';
|
|
6
13
|
import { resolveWorkingDir, validateTabName } from '../config.js';
|
|
7
14
|
import { logger } from '../util/logger.js';
|
|
8
15
|
import { logActivity } from '../timeline/index.js';
|
|
9
16
|
import { getAllKnowledge, formatKnowledgeForContext } from '../knowledge/index.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const MAX_QUEUE_SIZE = 10;
|
|
24
|
-
let pendingPollCount = 0;
|
|
17
|
+
/**
|
|
18
|
+
* TabManager orchestrates per-tab Claude subprocess lifecycles. Heavy lifting
|
|
19
|
+
* (queueing, budget enforcement, stale-session recovery, context compaction,
|
|
20
|
+
* pending-message dispatch) is delegated to collaborators in this directory
|
|
21
|
+
* so this class stays focused on:
|
|
22
|
+
*
|
|
23
|
+
* - tab CRUD (ensureTab, listTabs, stopTab, closeTab, stopAll)
|
|
24
|
+
* - subprocess orchestration (executeMessage)
|
|
25
|
+
* - wiring the collaborators together
|
|
26
|
+
*
|
|
27
|
+
* The split happened in the 2026-05-15 audit fix; see the audit file for the
|
|
28
|
+
* rationale. Before the split this file was 467 lines and 10 responsibilities.
|
|
29
|
+
*/
|
|
25
30
|
export class TabManager {
|
|
26
31
|
config;
|
|
27
32
|
subprocesses = new Map();
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
queue = new MessageQueue();
|
|
34
|
+
budget;
|
|
35
|
+
// In-memory cache of per-tab cumulative cost. Lazily seeded with a single
|
|
36
|
+
// SUM query when a tab is first checked; thereafter updated incrementally
|
|
37
|
+
// from the assistant-message insert. Saves an O(N messages) SUM scan on
|
|
38
|
+
// every inbound message — the previous code re-aggregated the entire tab
|
|
39
|
+
// history just to gate budget.
|
|
40
|
+
tabCostCache = new Map();
|
|
30
41
|
onNotify = null;
|
|
31
42
|
constructor(config) {
|
|
32
43
|
this.config = config;
|
|
44
|
+
this.budget = new BudgetGuard(config.claudeCode.maxBudgetUsd);
|
|
33
45
|
}
|
|
34
46
|
/** Set a callback for sending notifications (e.g., via Telegram) */
|
|
35
47
|
setNotifyCallback(cb) {
|
|
@@ -38,7 +50,7 @@ export class TabManager {
|
|
|
38
50
|
/** Ensure a tab exists in the database. Creates it if missing. */
|
|
39
51
|
ensureTab(tabName, workingDirOverride) {
|
|
40
52
|
const db = getDb();
|
|
41
|
-
const existing =
|
|
53
|
+
const existing = TabStore.findByName(tabName);
|
|
42
54
|
if (existing)
|
|
43
55
|
return existing;
|
|
44
56
|
// Validate tab name before creating (centralized for all channels + MCP)
|
|
@@ -72,94 +84,87 @@ export class TabManager {
|
|
|
72
84
|
const tab = this.ensureTab(tabName, options?.projectPath);
|
|
73
85
|
// If a subprocess is already running on this tab, queue the message
|
|
74
86
|
if (this.subprocesses.get(tabName)?.isRunning) {
|
|
75
|
-
const queue = this.messageQueues.get(tabName) ?? [];
|
|
76
|
-
if (queue.length >= MAX_QUEUE_SIZE) {
|
|
77
|
-
return Promise.reject(new Error(`Queue full for tab "${tabName}" (max ${MAX_QUEUE_SIZE}). Try again later.`));
|
|
78
|
-
}
|
|
79
87
|
return new Promise((resolve, reject) => {
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
const accepted = this.queue.enqueue(tabName, { prompt, resolve, reject });
|
|
89
|
+
if (!accepted) {
|
|
90
|
+
reject(new Error(`Queue full for tab "${tabName}". Try again later.`));
|
|
91
|
+
return;
|
|
82
92
|
}
|
|
83
|
-
|
|
84
|
-
logger.info(`[${tabName}] Message queued (queue size: ${this.messageQueues.get(tabName).length})`);
|
|
93
|
+
logger.info(`[${tabName}] Message queued (queue size: ${this.queue.size(tabName)})`);
|
|
85
94
|
});
|
|
86
95
|
}
|
|
87
96
|
return this.executeMessage(tab, prompt, options?.resume ?? false, options?.onTextChunk, options?.onToolUse, options?._compactionDepth);
|
|
88
97
|
}
|
|
89
98
|
/** Get all tabs from the database */
|
|
90
99
|
listTabs() {
|
|
91
|
-
|
|
92
|
-
return db.prepare('SELECT * FROM tabs ORDER BY last_activity_at DESC').all().map(rowToTab);
|
|
100
|
+
return TabStore.listAll();
|
|
93
101
|
}
|
|
94
102
|
/** Get a specific tab */
|
|
95
103
|
getTab(tabName) {
|
|
96
|
-
|
|
97
|
-
return this.queryTab(db, tabName);
|
|
98
|
-
}
|
|
99
|
-
queryTab(db, tabName) {
|
|
100
|
-
const row = db.prepare('SELECT * FROM tabs WHERE name = ?').get(tabName);
|
|
101
|
-
return row ? rowToTab(row) : undefined;
|
|
104
|
+
return TabStore.findByName(tabName);
|
|
102
105
|
}
|
|
103
106
|
/** Stop a tab's running subprocess */
|
|
104
107
|
stopTab(tabName) {
|
|
105
108
|
const sub = this.subprocesses.get(tabName);
|
|
106
|
-
if (sub?.isRunning)
|
|
109
|
+
if (sub?.isRunning)
|
|
107
110
|
sub.kill();
|
|
108
|
-
}
|
|
109
111
|
this.updateTabStatus(tabName, 'stopped');
|
|
110
112
|
this.clearQueue(tabName);
|
|
111
113
|
}
|
|
114
|
+
/** Update a tab's system_prompt. Returns true if the tab existed. */
|
|
115
|
+
setSystemPrompt(tabName, systemPrompt) {
|
|
116
|
+
return TabStore.setSystemPrompt(tabName, systemPrompt);
|
|
117
|
+
}
|
|
118
|
+
/** Close a tab — stop subprocess and delete its rows. Returns true if the tab existed. */
|
|
119
|
+
closeTab(tabName) {
|
|
120
|
+
const tab = TabStore.findByName(tabName);
|
|
121
|
+
this.stopTab(tabName);
|
|
122
|
+
if (tab)
|
|
123
|
+
this.tabCostCache.delete(tab.id);
|
|
124
|
+
return TabStore.deleteWithMessages(tabName);
|
|
125
|
+
}
|
|
112
126
|
/** Stop all running subprocesses (clean shutdown) */
|
|
113
127
|
stopAll() {
|
|
114
128
|
for (const [tabName, sub] of this.subprocesses) {
|
|
115
|
-
if (sub.isRunning)
|
|
129
|
+
if (sub.isRunning)
|
|
116
130
|
sub.kill();
|
|
117
|
-
}
|
|
118
131
|
this.updateTabStatus(tabName, 'stopped');
|
|
119
132
|
}
|
|
120
133
|
this.subprocesses.clear();
|
|
121
|
-
this.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
/** Process pending messages from MCP server IPC */
|
|
125
|
-
processPendingMessages() {
|
|
126
|
-
const db = getDb();
|
|
127
|
-
// Periodic cleanup: delete old processed messages every ~100 polls (~8 minutes at 5s interval)
|
|
128
|
-
pendingPollCount++;
|
|
129
|
-
if (pendingPollCount % 100 === 0) {
|
|
130
|
-
db.prepare("DELETE FROM pending_messages WHERE processed = 1 AND created_at < datetime('now', '-1 day')").run();
|
|
131
|
-
}
|
|
132
|
-
const pending = db.prepare('SELECT * FROM pending_messages WHERE processed = 0 ORDER BY created_at ASC LIMIT 50').all();
|
|
133
|
-
if (pending.length === 0)
|
|
134
|
-
return;
|
|
135
|
-
for (const msg of pending) {
|
|
136
|
-
db.prepare('UPDATE pending_messages SET processed = 1 WHERE id = ?').run(msg.id);
|
|
137
|
-
if (msg.type === 'notification') {
|
|
138
|
-
// Route notifications to Telegram/WhatsApp via the notify callback
|
|
139
|
-
this.onNotify?.(msg.message).catch(err => logger.warn('Notify failed:', err));
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
// Route regular messages to tabs
|
|
143
|
-
this.sendMessage(msg.tab_name, msg.message).catch(err => {
|
|
144
|
-
logger.error(`Failed to process pending message for tab ${msg.tab_name}:`, err);
|
|
145
|
-
});
|
|
134
|
+
for (const dropped of this.queue.clearAll()) {
|
|
135
|
+
for (const item of dropped) {
|
|
136
|
+
item.reject(new Error('Daemon shutting down'));
|
|
146
137
|
}
|
|
147
138
|
}
|
|
148
139
|
}
|
|
149
140
|
async executeMessage(tab, prompt, resume, onTextChunk, onToolUse, compactionDepth, forceFresh = false, retryDepth = 0) {
|
|
150
141
|
const db = getDb();
|
|
151
|
-
logActivity('task_started', 'Processing message', {
|
|
142
|
+
logActivity('task_started', 'Processing message', {
|
|
143
|
+
tabName: tab.name,
|
|
144
|
+
details: prompt.slice(0, 500),
|
|
145
|
+
});
|
|
152
146
|
// Budget check before spawning
|
|
153
147
|
if (this.config.claudeCode.maxBudgetUsd) {
|
|
154
|
-
|
|
155
|
-
if (tabSpend
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
148
|
+
let tabSpend = this.tabCostCache.get(tab.id);
|
|
149
|
+
if (tabSpend === undefined) {
|
|
150
|
+
tabSpend = db
|
|
151
|
+
.prepare('SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE tab_id = ?')
|
|
152
|
+
.get(tab.id).total;
|
|
153
|
+
this.tabCostCache.set(tab.id, tabSpend);
|
|
154
|
+
}
|
|
155
|
+
const decision = this.budget.check(tabSpend, tab.name);
|
|
156
|
+
if (!decision.allowed) {
|
|
157
|
+
this.onNotify?.(decision.reason).catch(() => { });
|
|
158
|
+
return {
|
|
159
|
+
text: decision.reason,
|
|
160
|
+
costUsd: 0,
|
|
161
|
+
durationMs: 0,
|
|
162
|
+
sessionId: tab.sessionId,
|
|
163
|
+
error: true,
|
|
164
|
+
};
|
|
159
165
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.onNotify?.(`⚠️ Budget warning: tab "${tab.name}" at $${tabSpend.toFixed(2)} / $${this.config.claudeCode.maxBudgetUsd.toFixed(2)} (80%)`).catch(() => { });
|
|
166
|
+
if (decision.warning) {
|
|
167
|
+
this.onNotify?.(decision.warning).catch(() => { });
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
170
|
// Inject knowledge (global markdown + project markdown + tab facts)
|
|
@@ -167,33 +172,42 @@ export class TabManager {
|
|
|
167
172
|
const knowledgeContext = formatKnowledgeForContext(knowledge);
|
|
168
173
|
const enrichedPrompt = knowledgeContext ? `${knowledgeContext}\n\n${prompt}` : prompt;
|
|
169
174
|
// Store user message
|
|
170
|
-
db.prepare('INSERT INTO messages (tab_id, role, content) VALUES (?, ?, ?)')
|
|
171
|
-
.run(tab.id, 'user', prompt);
|
|
175
|
+
db.prepare('INSERT INTO messages (tab_id, role, content) VALUES (?, ?, ?)').run(tab.id, 'user', prompt);
|
|
172
176
|
this.updateTabStatus(tab.name, 'running');
|
|
173
177
|
// Get fresh tab to pick up system_prompt
|
|
174
|
-
const freshTab =
|
|
178
|
+
const freshTab = TabStore.findByName(tab.name) || tab;
|
|
175
179
|
const subprocess = new ClaudeSubprocess(tab.name, tab.workingDir, this.config, tab.sessionId, freshTab.systemPrompt);
|
|
176
180
|
this.subprocesses.set(tab.name, subprocess);
|
|
181
|
+
// Circuit breaker is per-call state — its lifetime is exactly the
|
|
182
|
+
// subprocess's lifetime. Keep it as a local const; the previous
|
|
183
|
+
// circuitBreakers Map suggested cross-call state but nothing read it
|
|
184
|
+
// outside this scope.
|
|
177
185
|
const breaker = new CircuitBreaker(tab.name);
|
|
178
|
-
this.circuitBreakers.set(tab.name, breaker);
|
|
179
186
|
const contextMonitor = new ContextMonitor(tab.name);
|
|
180
187
|
// Resume if: explicitly requested or DB has prior successful responses for this tab.
|
|
181
188
|
// forceFresh overrides both — used after a stale-session retry to guarantee --session-id, not --resume.
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
// LIMIT 1 short-circuits on the first matching row — a long-running tab with
|
|
190
|
+
// 10k messages used to walk the whole index for a boolean answer.
|
|
191
|
+
const hasDbHistory = db
|
|
192
|
+
.prepare("SELECT 1 FROM messages WHERE tab_id = ? AND role = 'assistant' AND content != '' LIMIT 1")
|
|
193
|
+
.get(tab.id) !== undefined;
|
|
194
|
+
const shouldResume = !forceFresh && (resume || hasDbHistory);
|
|
184
195
|
return new Promise((resolve, reject) => {
|
|
185
196
|
let resultText = '';
|
|
186
197
|
let resultEvent = null;
|
|
187
198
|
let loopWarningPending = false;
|
|
188
199
|
let checkpointTriggered = false;
|
|
200
|
+
// Prevent the SIGTERM-then-exit double dispatch: when onError already
|
|
201
|
+
// routed the failure through reject() + processNextInQueue, suppress
|
|
202
|
+
// the inevitable subsequent onExit so the queue doesn't shift twice.
|
|
203
|
+
let errored = false;
|
|
189
204
|
const callbacks = {
|
|
190
205
|
onEvent: (event) => {
|
|
191
206
|
// Capture session_id from StreamInit and update tab record
|
|
192
207
|
if (event.type === 'system' && 'subtype' in event && event.subtype === 'init') {
|
|
193
208
|
const initEvent = event;
|
|
194
209
|
if (initEvent.session_id) {
|
|
195
|
-
db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?')
|
|
196
|
-
.run(initEvent.session_id, tab.id);
|
|
210
|
+
db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(initEvent.session_id, tab.id);
|
|
197
211
|
}
|
|
198
212
|
}
|
|
199
213
|
if (event.type === 'assistant') {
|
|
@@ -202,7 +216,6 @@ export class TabManager {
|
|
|
202
216
|
if (assistant.message.usage) {
|
|
203
217
|
const contextAction = contextMonitor.recordUsage(assistant.message.usage);
|
|
204
218
|
if (contextAction === 'warn') {
|
|
205
|
-
// Will inject warning on next message
|
|
206
219
|
logger.info(`[${tab.name}] Context window warning — will summarize on next turn`);
|
|
207
220
|
}
|
|
208
221
|
else if (contextAction === 'checkpoint') {
|
|
@@ -224,7 +237,7 @@ export class TabManager {
|
|
|
224
237
|
subprocess.kill();
|
|
225
238
|
}
|
|
226
239
|
else if (action === 'notify') {
|
|
227
|
-
this.onNotify?.(`Loop detected in tab "${tab.name}": ${toolUse.name} repeated 10+ times. Send /stop ${tab.name} to kill it.`).catch(err => logger.warn('Notify failed:', err));
|
|
240
|
+
this.onNotify?.(`Loop detected in tab "${tab.name}": ${toolUse.name} repeated 10+ times. Send /stop ${tab.name} to kill it.`).catch((err) => logger.warn('Notify failed:', err));
|
|
228
241
|
}
|
|
229
242
|
else if (action === 'warn') {
|
|
230
243
|
loopWarningPending = true;
|
|
@@ -237,111 +250,116 @@ export class TabManager {
|
|
|
237
250
|
}
|
|
238
251
|
},
|
|
239
252
|
onExit: (code) => {
|
|
253
|
+
if (errored) {
|
|
254
|
+
// onError already rejected and drained the queue; don't double-dispatch.
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
240
257
|
this.subprocesses.delete(tab.name);
|
|
241
|
-
this.circuitBreakers.delete(tab.name);
|
|
242
258
|
const result = {
|
|
243
259
|
text: resultEvent?.result ?? resultText,
|
|
244
260
|
costUsd: resultEvent?.total_cost_usd ?? 0,
|
|
245
261
|
durationMs: resultEvent?.duration_ms ?? 0,
|
|
246
262
|
sessionId: subprocess.sessionId,
|
|
247
|
-
error: resultEvent?.is_error ??
|
|
263
|
+
error: resultEvent?.is_error ?? code !== 0,
|
|
248
264
|
};
|
|
249
265
|
// Handle resume failure (Claude Code session cache rotated / never existed / expired).
|
|
250
|
-
|
|
251
|
-
// event shape ({"subtype":"error_during_execution","errors":["No conversation found..."]}).
|
|
252
|
-
// retryDepth guards against any pathological loop.
|
|
253
|
-
const staleSession = result.error && shouldResume && retryDepth === 0 && (result.text.match(/session (not found|expired|invalid)/i) !== null ||
|
|
254
|
-
(resultEvent?.subtype === 'error_during_execution' &&
|
|
255
|
-
resultEvent.errors?.some(e => /no conversation found|session.*not found|session.*expired|session.*invalid/i.test(e))));
|
|
256
|
-
if (staleSession) {
|
|
266
|
+
if (StaleSessionDetector.isStale(result, resultEvent, shouldResume, retryDepth)) {
|
|
257
267
|
const detail = resultEvent?.errors?.[0] ?? result.text.split('\n')[0];
|
|
258
268
|
logger.warn(`[${tab.name}] Resume session ${tab.sessionId} unavailable in Claude Code cache (${detail}). Retrying with fresh session.`);
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// Reset session ID for fresh start. Use --session-id (forceFresh) to bypass the
|
|
265
|
-
// hasDbHistory shouldResume override that would otherwise --resume the new UUID
|
|
266
|
-
// against an equally-empty Claude Code cache.
|
|
267
|
-
const newSessionId = uuidv4();
|
|
268
|
-
db.prepare('UPDATE tabs SET session_id = ?, status = ? WHERE id = ?').run(newSessionId, 'idle', tab.id);
|
|
269
|
-
this.executeMessage({ ...tab, sessionId: newSessionId }, contextPrompt, false, onTextChunk, onToolUse, compactionDepth, true, retryDepth + 1)
|
|
270
|
-
.then(resolve).catch(reject);
|
|
269
|
+
const recovery = StaleSessionDetector.buildRecovery(tab.id, enrichedPrompt, db);
|
|
270
|
+
db.prepare('UPDATE tabs SET session_id = ?, status = ? WHERE id = ?').run(recovery.newSessionId, 'idle', tab.id);
|
|
271
|
+
this.executeMessage({ ...tab, sessionId: recovery.newSessionId }, recovery.contextPrompt, false, onTextChunk, onToolUse, compactionDepth, true, retryDepth + 1)
|
|
272
|
+
.then(resolve)
|
|
273
|
+
.catch(reject);
|
|
271
274
|
return;
|
|
272
275
|
}
|
|
273
276
|
// Store assistant response. Skip empty content (typically failed/error runs) so
|
|
274
277
|
// it doesn't trigger the hasDbHistory shouldResume override on future calls.
|
|
275
278
|
if (result.text.trim() !== '') {
|
|
276
279
|
db.prepare('INSERT INTO messages (tab_id, role, content, cost_usd, tokens_in, tokens_out) VALUES (?, ?, ?, ?, ?, ?)').run(tab.id, 'assistant', result.text, result.costUsd, resultEvent?.usage?.input_tokens ?? null, resultEvent?.usage?.output_tokens ?? null);
|
|
280
|
+
// Keep the budget cache in sync with the row we just inserted —
|
|
281
|
+
// avoids the per-message SUM scan in the next budget check.
|
|
282
|
+
if (this.tabCostCache.has(tab.id)) {
|
|
283
|
+
this.tabCostCache.set(tab.id, (this.tabCostCache.get(tab.id) ?? 0) + (result.costUsd || 0));
|
|
284
|
+
}
|
|
277
285
|
}
|
|
278
286
|
// Update tab
|
|
279
|
-
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?')
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
287
|
+
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?').run('idle', new Date().toISOString(), tab.name);
|
|
288
|
+
logActivity('task_completed', 'Message processed', {
|
|
289
|
+
tabName: tab.name,
|
|
290
|
+
durationMs: result.durationMs,
|
|
291
|
+
costUsd: result.costUsd,
|
|
292
|
+
details: result.text.slice(0, 500),
|
|
293
|
+
});
|
|
294
|
+
// Context window compaction: if checkpoint was triggered, restart with summary.
|
|
283
295
|
const currentDepth = compactionDepth ?? 0;
|
|
284
|
-
if (checkpointTriggered &&
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
this.sendMessage(tab.name, continuationPrompt, { onTextChunk, _compactionDepth: currentDepth + 1 }).then(resolve).catch(reject);
|
|
297
|
-
}).catch(err => {
|
|
298
|
-
logger.error(`[${tab.name}] Compaction failed:`, err);
|
|
299
|
-
resolve(result); // Fall back to returning the original result
|
|
300
|
-
});
|
|
301
|
-
return; // Don't resolve yet — compaction flow will resolve
|
|
296
|
+
if (checkpointTriggered &&
|
|
297
|
+
!result.error &&
|
|
298
|
+
result.text &&
|
|
299
|
+
currentDepth < ContextCompactor.MAX_DEPTH) {
|
|
300
|
+
// Compactor handles its own try/catch and always returns a SendResult.
|
|
301
|
+
// The queue drain MUST happen regardless of compaction outcome — the
|
|
302
|
+
// previous pattern omitted it in this branch, so a queued message
|
|
303
|
+
// could wait indefinitely while compaction recursion was in flight.
|
|
304
|
+
ContextCompactor.compact(tab, enrichedPrompt, result, onTextChunk, currentDepth, (tabName, p, opts) => this.sendMessage(tabName, p, opts), this.onNotify, db)
|
|
305
|
+
.then(resolve)
|
|
306
|
+
.finally(() => this.processNextInQueue(tab.name));
|
|
307
|
+
return;
|
|
302
308
|
}
|
|
303
309
|
// Check for delegation completion
|
|
304
|
-
|
|
310
|
+
try {
|
|
305
311
|
const delegation = completeDelegation(tab.name, result.text);
|
|
306
312
|
if (delegation && delegation.returnToTab) {
|
|
307
|
-
|
|
308
|
-
|
|
313
|
+
PendingMessageStore.enqueueDelegationResult(delegation.returnToTab, `[Result from tab:${tab.name}]: ${result.text.slice(0, 10000)}`);
|
|
314
|
+
logActivity('delegation_completed', `Delegation ${tab.name} → ${delegation.returnToTab}`, {
|
|
315
|
+
tabName: tab.name,
|
|
316
|
+
details: result.text.slice(0, 500),
|
|
317
|
+
});
|
|
309
318
|
this.onNotify?.(`Delegation complete: ${tab.name} → result sent back to ${delegation.returnToTab}`).catch(() => { });
|
|
310
319
|
logger.info(`Delegation result sent: ${tab.name} → ${delegation.returnToTab}`);
|
|
311
320
|
}
|
|
312
|
-
}
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
313
323
|
logger.warn('Delegation completion check failed:', err);
|
|
314
|
-
}
|
|
324
|
+
}
|
|
315
325
|
resolve(result);
|
|
316
326
|
// Process next queued message (prepend loop warning if needed)
|
|
317
|
-
if (loopWarningPending
|
|
318
|
-
const next = this.
|
|
319
|
-
next
|
|
320
|
-
|
|
327
|
+
if (loopWarningPending) {
|
|
328
|
+
const next = this.queue.peek(tab.name);
|
|
329
|
+
if (next) {
|
|
330
|
+
next.prompt = `[WARNING: You appear to be repeating the same action. Reassess your approach.]\n\n${next.prompt}`;
|
|
331
|
+
loopWarningPending = false;
|
|
332
|
+
}
|
|
321
333
|
}
|
|
322
334
|
this.processNextInQueue(tab.name);
|
|
323
335
|
},
|
|
324
336
|
onError: (err) => {
|
|
337
|
+
errored = true;
|
|
325
338
|
this.subprocesses.delete(tab.name);
|
|
326
|
-
this.circuitBreakers.delete(tab.name);
|
|
327
339
|
this.updateTabStatus(tab.name, 'error');
|
|
328
|
-
logActivity('task_failed', 'Message failed', {
|
|
340
|
+
logActivity('task_failed', 'Message failed', {
|
|
341
|
+
tabName: tab.name,
|
|
342
|
+
details: err instanceof Error ? err.message : String(err),
|
|
343
|
+
});
|
|
329
344
|
reject(err);
|
|
330
345
|
this.processNextInQueue(tab.name);
|
|
331
346
|
},
|
|
332
347
|
};
|
|
333
|
-
subprocess.send(enrichedPrompt, callbacks, shouldResume).catch(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
348
|
+
subprocess.send(enrichedPrompt, callbacks, shouldResume).catch((err) => {
|
|
349
|
+
if (!errored) {
|
|
350
|
+
errored = true;
|
|
351
|
+
this.subprocesses.delete(tab.name);
|
|
352
|
+
this.updateTabStatus(tab.name, 'error');
|
|
353
|
+
reject(err);
|
|
354
|
+
this.processNextInQueue(tab.name);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
338
357
|
});
|
|
339
358
|
}
|
|
340
359
|
processNextInQueue(tabName) {
|
|
341
|
-
const
|
|
342
|
-
if (!
|
|
360
|
+
const next = this.queue.dequeue(tabName);
|
|
361
|
+
if (!next)
|
|
343
362
|
return;
|
|
344
|
-
const next = queue.shift();
|
|
345
363
|
const tab = this.getTab(tabName);
|
|
346
364
|
if (!tab) {
|
|
347
365
|
next.reject(new Error(`Tab "${tabName}" not found`));
|
|
@@ -350,17 +368,12 @@ export class TabManager {
|
|
|
350
368
|
this.executeMessage(tab, next.prompt, false).then(next.resolve).catch(next.reject);
|
|
351
369
|
}
|
|
352
370
|
updateTabStatus(tabName, status) {
|
|
353
|
-
|
|
354
|
-
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ? WHERE name = ?')
|
|
355
|
-
.run(status, new Date().toISOString(), tabName);
|
|
371
|
+
TabStore.setStatus(tabName, status);
|
|
356
372
|
}
|
|
357
373
|
clearQueue(tabName) {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
item.reject(new Error(`Tab "${tabName}" was stopped`));
|
|
362
|
-
}
|
|
363
|
-
this.messageQueues.delete(tabName);
|
|
374
|
+
const dropped = this.queue.clear(tabName);
|
|
375
|
+
for (const item of dropped) {
|
|
376
|
+
item.reject(new Error(`Tab "${tabName}" was stopped`));
|
|
364
377
|
}
|
|
365
378
|
}
|
|
366
379
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small store wrapping the `memories` table. Used by the MCP and dashboard
|
|
3
|
+
* layers instead of inline `INSERT INTO memories` so schema details stay in
|
|
4
|
+
* one place.
|
|
5
|
+
*/
|
|
6
|
+
import type Database from 'better-sqlite3';
|
|
7
|
+
export interface MemoryRow {
|
|
8
|
+
id: number;
|
|
9
|
+
content: string;
|
|
10
|
+
tabName: string | null;
|
|
11
|
+
source: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const MemoryStore: {
|
|
15
|
+
add(content: string, opts?: {
|
|
16
|
+
tabName?: string | null;
|
|
17
|
+
source?: string;
|
|
18
|
+
}, db?: Database.Database): void;
|
|
19
|
+
delete(id: number | string, db?: Database.Database): boolean;
|
|
20
|
+
count(db?: Database.Database): number;
|
|
21
|
+
list(opts?: {
|
|
22
|
+
limit?: number;
|
|
23
|
+
offset?: number;
|
|
24
|
+
query?: string;
|
|
25
|
+
}, db?: Database.Database): {
|
|
26
|
+
memories: MemoryRow[];
|
|
27
|
+
total: number;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small store wrapping the `memories` table. Used by the MCP and dashboard
|
|
3
|
+
* layers instead of inline `INSERT INTO memories` so schema details stay in
|
|
4
|
+
* one place.
|
|
5
|
+
*/
|
|
6
|
+
import { getDb } from '../db/index.js';
|
|
7
|
+
function rowToMemory(r) {
|
|
8
|
+
return {
|
|
9
|
+
id: r.id,
|
|
10
|
+
content: r.content,
|
|
11
|
+
tabName: r.tab_name,
|
|
12
|
+
source: r.source,
|
|
13
|
+
createdAt: r.created_at,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const MemoryStore = {
|
|
17
|
+
add(content, opts, db = getDb()) {
|
|
18
|
+
db.prepare('INSERT INTO memories (content, tab_name, source) VALUES (?, ?, ?)').run(content, opts?.tabName ?? null, opts?.source ?? 'tool');
|
|
19
|
+
},
|
|
20
|
+
delete(id, db = getDb()) {
|
|
21
|
+
const result = db.prepare('DELETE FROM memories WHERE id = ?').run(id);
|
|
22
|
+
return result.changes > 0;
|
|
23
|
+
},
|
|
24
|
+
count(db = getDb()) {
|
|
25
|
+
return db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
|
|
26
|
+
},
|
|
27
|
+
list(opts = {}, db = getDb()) {
|
|
28
|
+
const limit = opts.limit ?? 50;
|
|
29
|
+
const offset = opts.offset ?? 0;
|
|
30
|
+
if (opts.query) {
|
|
31
|
+
const rows = db
|
|
32
|
+
.prepare('SELECT id, content, tab_name, source, created_at FROM memories WHERE content LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?')
|
|
33
|
+
.all(`%${opts.query}%`, limit, offset);
|
|
34
|
+
const total = db
|
|
35
|
+
.prepare('SELECT COUNT(*) as c FROM memories WHERE content LIKE ?')
|
|
36
|
+
.get(`%${opts.query}%`).c;
|
|
37
|
+
return { memories: rows.map(rowToMemory), total };
|
|
38
|
+
}
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare('SELECT id, content, tab_name, source, created_at FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?')
|
|
41
|
+
.all(limit, offset);
|
|
42
|
+
const total = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
|
|
43
|
+
return { memories: rows.map(rowToMemory), total };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
import type { SendResult } from './manager.js';
|
|
9
|
+
export declare const MAX_QUEUE_SIZE = 10;
|
|
10
|
+
export interface QueuedMessage {
|
|
11
|
+
prompt: string;
|
|
12
|
+
resolve: (r: SendResult) => void;
|
|
13
|
+
reject: (e: Error) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare class MessageQueue {
|
|
16
|
+
private queues;
|
|
17
|
+
/** Attempt to enqueue. Returns false if the per-tab queue is full. */
|
|
18
|
+
enqueue(tabName: string, msg: QueuedMessage): boolean;
|
|
19
|
+
/** Remove and return the next message. Drops the per-tab entry when empty. */
|
|
20
|
+
dequeue(tabName: string): QueuedMessage | undefined;
|
|
21
|
+
/** Peek at the next queued message without removing it (used to inject loop warning prefix). */
|
|
22
|
+
peek(tabName: string): QueuedMessage | undefined;
|
|
23
|
+
size(tabName: string): number;
|
|
24
|
+
/** Drain the queue for a tab, returning the dropped messages so callers can reject them. */
|
|
25
|
+
clear(tabName: string): QueuedMessage[];
|
|
26
|
+
/** Drain every queue. Returns the dropped messages grouped by tab. */
|
|
27
|
+
clearAll(): QueuedMessage[][];
|
|
28
|
+
}
|