beecork 1.5.0 → 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/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 +6 -1
- package/dist/session/tool-classifier.d.ts +0 -4
- package/dist/session/tool-classifier.js +0 -56
package/dist/session/manager.js
CHANGED
|
@@ -3,21 +3,45 @@ import { ClaudeSubprocess } from './subprocess.js';
|
|
|
3
3
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
4
4
|
import { ContextMonitor } from './context-monitor.js';
|
|
5
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';
|
|
6
12
|
import { getDb } from '../db/index.js';
|
|
7
13
|
import { resolveWorkingDir, validateTabName } from '../config.js';
|
|
8
14
|
import { logger } from '../util/logger.js';
|
|
9
15
|
import { logActivity } from '../timeline/index.js';
|
|
10
16
|
import { getAllKnowledge, formatKnowledgeForContext } from '../knowledge/index.js';
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
*/
|
|
13
30
|
export class TabManager {
|
|
14
31
|
config;
|
|
15
32
|
subprocesses = new Map();
|
|
16
|
-
|
|
17
|
-
|
|
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();
|
|
18
41
|
onNotify = null;
|
|
19
42
|
constructor(config) {
|
|
20
43
|
this.config = config;
|
|
44
|
+
this.budget = new BudgetGuard(config.claudeCode.maxBudgetUsd);
|
|
21
45
|
}
|
|
22
46
|
/** Set a callback for sending notifications (e.g., via Telegram) */
|
|
23
47
|
setNotifyCallback(cb) {
|
|
@@ -26,7 +50,7 @@ export class TabManager {
|
|
|
26
50
|
/** Ensure a tab exists in the database. Creates it if missing. */
|
|
27
51
|
ensureTab(tabName, workingDirOverride) {
|
|
28
52
|
const db = getDb();
|
|
29
|
-
const existing =
|
|
53
|
+
const existing = TabStore.findByName(tabName);
|
|
30
54
|
if (existing)
|
|
31
55
|
return existing;
|
|
32
56
|
// Validate tab name before creating (centralized for all channels + MCP)
|
|
@@ -60,16 +84,13 @@ export class TabManager {
|
|
|
60
84
|
const tab = this.ensureTab(tabName, options?.projectPath);
|
|
61
85
|
// If a subprocess is already running on this tab, queue the message
|
|
62
86
|
if (this.subprocesses.get(tabName)?.isRunning) {
|
|
63
|
-
const queue = this.messageQueues.get(tabName) ?? [];
|
|
64
|
-
if (queue.length >= MAX_QUEUE_SIZE) {
|
|
65
|
-
return Promise.reject(new Error(`Queue full for tab "${tabName}" (max ${MAX_QUEUE_SIZE}). Try again later.`));
|
|
66
|
-
}
|
|
67
87
|
return new Promise((resolve, reject) => {
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
70
92
|
}
|
|
71
|
-
|
|
72
|
-
logger.info(`[${tabName}] Message queued (queue size: ${this.messageQueues.get(tabName).length})`);
|
|
93
|
+
logger.info(`[${tabName}] Message queued (queue size: ${this.queue.size(tabName)})`);
|
|
73
94
|
});
|
|
74
95
|
}
|
|
75
96
|
return this.executeMessage(tab, prompt, options?.resume ?? false, options?.onTextChunk, options?.onToolUse, options?._compactionDepth);
|
|
@@ -82,15 +103,11 @@ export class TabManager {
|
|
|
82
103
|
getTab(tabName) {
|
|
83
104
|
return TabStore.findByName(tabName);
|
|
84
105
|
}
|
|
85
|
-
queryTab(_db, tabName) {
|
|
86
|
-
return TabStore.findByName(tabName);
|
|
87
|
-
}
|
|
88
106
|
/** Stop a tab's running subprocess */
|
|
89
107
|
stopTab(tabName) {
|
|
90
108
|
const sub = this.subprocesses.get(tabName);
|
|
91
|
-
if (sub?.isRunning)
|
|
109
|
+
if (sub?.isRunning)
|
|
92
110
|
sub.kill();
|
|
93
|
-
}
|
|
94
111
|
this.updateTabStatus(tabName, 'stopped');
|
|
95
112
|
this.clearQueue(tabName);
|
|
96
113
|
}
|
|
@@ -100,67 +117,54 @@ export class TabManager {
|
|
|
100
117
|
}
|
|
101
118
|
/** Close a tab — stop subprocess and delete its rows. Returns true if the tab existed. */
|
|
102
119
|
closeTab(tabName) {
|
|
120
|
+
const tab = TabStore.findByName(tabName);
|
|
103
121
|
this.stopTab(tabName);
|
|
122
|
+
if (tab)
|
|
123
|
+
this.tabCostCache.delete(tab.id);
|
|
104
124
|
return TabStore.deleteWithMessages(tabName);
|
|
105
125
|
}
|
|
106
126
|
/** Stop all running subprocesses (clean shutdown) */
|
|
107
127
|
stopAll() {
|
|
108
128
|
for (const [tabName, sub] of this.subprocesses) {
|
|
109
|
-
if (sub.isRunning)
|
|
129
|
+
if (sub.isRunning)
|
|
110
130
|
sub.kill();
|
|
111
|
-
}
|
|
112
131
|
this.updateTabStatus(tabName, 'stopped');
|
|
113
132
|
}
|
|
114
133
|
this.subprocesses.clear();
|
|
115
|
-
this.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
/** Process pending messages from MCP server IPC */
|
|
119
|
-
processPendingMessages() {
|
|
120
|
-
const db = getDb();
|
|
121
|
-
// Periodic cleanup: delete old processed messages every ~100 polls (~8 minutes at 5s interval)
|
|
122
|
-
pendingPollCount++;
|
|
123
|
-
if (pendingPollCount % 100 === 0) {
|
|
124
|
-
db.prepare("DELETE FROM pending_messages WHERE processed = 1 AND created_at < datetime('now', '-1 day')").run();
|
|
125
|
-
}
|
|
126
|
-
const pending = db.prepare('SELECT * FROM pending_messages WHERE processed = 0 ORDER BY created_at ASC LIMIT 50').all();
|
|
127
|
-
if (pending.length === 0)
|
|
128
|
-
return;
|
|
129
|
-
const markProcessed = db.prepare('UPDATE pending_messages SET processed = 1 WHERE id = ?');
|
|
130
|
-
for (const msg of pending) {
|
|
131
|
-
// Mark processed AFTER delivery resolves (success OR failure) so a crash
|
|
132
|
-
// mid-loop doesn't leave the row both unprocessed in the DB and dropped
|
|
133
|
-
// from the in-memory iteration. Failures still mark processed — we don't
|
|
134
|
-
// want infinite retries — but the failure path also logs and notifies.
|
|
135
|
-
if (msg.type === 'notification') {
|
|
136
|
-
this.onNotify?.(msg.message)
|
|
137
|
-
.catch(err => logger.warn('Notify failed:', err))
|
|
138
|
-
.finally(() => markProcessed.run(msg.id));
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
this.sendMessage(msg.tab_name, msg.message)
|
|
142
|
-
.catch(err => {
|
|
143
|
-
logger.error(`Failed to process pending message for tab ${msg.tab_name}:`, err);
|
|
144
|
-
this.onNotify?.(`Pending message for tab "${msg.tab_name}" failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
145
|
-
})
|
|
146
|
-
.finally(() => markProcessed.run(msg.id));
|
|
134
|
+
for (const dropped of this.queue.clearAll()) {
|
|
135
|
+
for (const item of dropped) {
|
|
136
|
+
item.reject(new Error('Daemon shutting down'));
|
|
147
137
|
}
|
|
148
138
|
}
|
|
149
139
|
}
|
|
150
140
|
async executeMessage(tab, prompt, resume, onTextChunk, onToolUse, compactionDepth, forceFresh = false, retryDepth = 0) {
|
|
151
141
|
const db = getDb();
|
|
152
|
-
logActivity('task_started', 'Processing message', {
|
|
142
|
+
logActivity('task_started', 'Processing message', {
|
|
143
|
+
tabName: tab.name,
|
|
144
|
+
details: prompt.slice(0, 500),
|
|
145
|
+
});
|
|
153
146
|
// Budget check before spawning
|
|
154
147
|
if (this.config.claudeCode.maxBudgetUsd) {
|
|
155
|
-
|
|
156
|
-
if (tabSpend
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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);
|
|
160
154
|
}
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
this.onNotify?.(
|
|
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
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (decision.warning) {
|
|
167
|
+
this.onNotify?.(decision.warning).catch(() => { });
|
|
164
168
|
}
|
|
165
169
|
}
|
|
166
170
|
// Inject knowledge (global markdown + project markdown + tab facts)
|
|
@@ -168,33 +172,42 @@ export class TabManager {
|
|
|
168
172
|
const knowledgeContext = formatKnowledgeForContext(knowledge);
|
|
169
173
|
const enrichedPrompt = knowledgeContext ? `${knowledgeContext}\n\n${prompt}` : prompt;
|
|
170
174
|
// Store user message
|
|
171
|
-
db.prepare('INSERT INTO messages (tab_id, role, content) VALUES (?, ?, ?)')
|
|
172
|
-
.run(tab.id, 'user', prompt);
|
|
175
|
+
db.prepare('INSERT INTO messages (tab_id, role, content) VALUES (?, ?, ?)').run(tab.id, 'user', prompt);
|
|
173
176
|
this.updateTabStatus(tab.name, 'running');
|
|
174
177
|
// Get fresh tab to pick up system_prompt
|
|
175
|
-
const freshTab =
|
|
178
|
+
const freshTab = TabStore.findByName(tab.name) || tab;
|
|
176
179
|
const subprocess = new ClaudeSubprocess(tab.name, tab.workingDir, this.config, tab.sessionId, freshTab.systemPrompt);
|
|
177
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.
|
|
178
185
|
const breaker = new CircuitBreaker(tab.name);
|
|
179
|
-
this.circuitBreakers.set(tab.name, breaker);
|
|
180
186
|
const contextMonitor = new ContextMonitor(tab.name);
|
|
181
187
|
// Resume if: explicitly requested or DB has prior successful responses for this tab.
|
|
182
188
|
// forceFresh overrides both — used after a stale-session retry to guarantee --session-id, not --resume.
|
|
183
|
-
|
|
184
|
-
|
|
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);
|
|
185
195
|
return new Promise((resolve, reject) => {
|
|
186
196
|
let resultText = '';
|
|
187
197
|
let resultEvent = null;
|
|
188
198
|
let loopWarningPending = false;
|
|
189
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;
|
|
190
204
|
const callbacks = {
|
|
191
205
|
onEvent: (event) => {
|
|
192
206
|
// Capture session_id from StreamInit and update tab record
|
|
193
207
|
if (event.type === 'system' && 'subtype' in event && event.subtype === 'init') {
|
|
194
208
|
const initEvent = event;
|
|
195
209
|
if (initEvent.session_id) {
|
|
196
|
-
db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?')
|
|
197
|
-
.run(initEvent.session_id, tab.id);
|
|
210
|
+
db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(initEvent.session_id, tab.id);
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
if (event.type === 'assistant') {
|
|
@@ -203,7 +216,6 @@ export class TabManager {
|
|
|
203
216
|
if (assistant.message.usage) {
|
|
204
217
|
const contextAction = contextMonitor.recordUsage(assistant.message.usage);
|
|
205
218
|
if (contextAction === 'warn') {
|
|
206
|
-
// Will inject warning on next message
|
|
207
219
|
logger.info(`[${tab.name}] Context window warning — will summarize on next turn`);
|
|
208
220
|
}
|
|
209
221
|
else if (contextAction === 'checkpoint') {
|
|
@@ -225,7 +237,7 @@ export class TabManager {
|
|
|
225
237
|
subprocess.kill();
|
|
226
238
|
}
|
|
227
239
|
else if (action === 'notify') {
|
|
228
|
-
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));
|
|
229
241
|
}
|
|
230
242
|
else if (action === 'warn') {
|
|
231
243
|
loopWarningPending = true;
|
|
@@ -238,116 +250,116 @@ export class TabManager {
|
|
|
238
250
|
}
|
|
239
251
|
},
|
|
240
252
|
onExit: (code) => {
|
|
253
|
+
if (errored) {
|
|
254
|
+
// onError already rejected and drained the queue; don't double-dispatch.
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
241
257
|
this.subprocesses.delete(tab.name);
|
|
242
|
-
this.circuitBreakers.delete(tab.name);
|
|
243
258
|
const result = {
|
|
244
259
|
text: resultEvent?.result ?? resultText,
|
|
245
260
|
costUsd: resultEvent?.total_cost_usd ?? 0,
|
|
246
261
|
durationMs: resultEvent?.duration_ms ?? 0,
|
|
247
262
|
sessionId: subprocess.sessionId,
|
|
248
|
-
error: resultEvent?.is_error ??
|
|
263
|
+
error: resultEvent?.is_error ?? code !== 0,
|
|
249
264
|
};
|
|
250
265
|
// Handle resume failure (Claude Code session cache rotated / never existed / expired).
|
|
251
|
-
|
|
252
|
-
// event shape ({"subtype":"error_during_execution","errors":["No conversation found..."]}).
|
|
253
|
-
// retryDepth guards against any pathological loop.
|
|
254
|
-
const staleSession = result.error && shouldResume && retryDepth === 0 && (result.text.match(/session (not found|expired|invalid)/i) !== null ||
|
|
255
|
-
(resultEvent?.subtype === 'error_during_execution' &&
|
|
256
|
-
resultEvent.errors?.some(e => /no conversation found|session.*not found|session.*expired|session.*invalid/i.test(e))));
|
|
257
|
-
if (staleSession) {
|
|
266
|
+
if (StaleSessionDetector.isStale(result, resultEvent, shouldResume, retryDepth)) {
|
|
258
267
|
const detail = resultEvent?.errors?.[0] ?? result.text.split('\n')[0];
|
|
259
268
|
logger.warn(`[${tab.name}] Resume session ${tab.sessionId} unavailable in Claude Code cache (${detail}). Retrying with fresh session.`);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// Reset session ID for fresh start. Use --session-id (forceFresh) to bypass the
|
|
266
|
-
// hasDbHistory shouldResume override that would otherwise --resume the new UUID
|
|
267
|
-
// against an equally-empty Claude Code cache.
|
|
268
|
-
const newSessionId = uuidv4();
|
|
269
|
-
db.prepare('UPDATE tabs SET session_id = ?, status = ? WHERE id = ?').run(newSessionId, 'idle', tab.id);
|
|
270
|
-
this.executeMessage({ ...tab, sessionId: newSessionId }, contextPrompt, false, onTextChunk, onToolUse, compactionDepth, true, retryDepth + 1)
|
|
271
|
-
.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);
|
|
272
274
|
return;
|
|
273
275
|
}
|
|
274
276
|
// Store assistant response. Skip empty content (typically failed/error runs) so
|
|
275
277
|
// it doesn't trigger the hasDbHistory shouldResume override on future calls.
|
|
276
278
|
if (result.text.trim() !== '') {
|
|
277
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
|
+
}
|
|
278
285
|
}
|
|
279
286
|
// Update tab
|
|
280
|
-
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?')
|
|
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
|
+
});
|
|
283
294
|
// Context window compaction: if checkpoint was triggered, restart with summary.
|
|
284
295
|
const currentDepth = compactionDepth ?? 0;
|
|
285
|
-
if (checkpointTriggered &&
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
logger.info(`[${tab.name}] Context compacted — new session ${newSessionId.slice(0, 8)}...`);
|
|
298
|
-
const continuationPrompt = `[CONTEXT RESTORED FROM PREVIOUS SESSION]\n${summaryResult.text}\n\n[Continue the original task: "${enrichedPrompt.slice(0, 500)}"]`;
|
|
299
|
-
const continuation = await this.sendMessage(tab.name, continuationPrompt, { onTextChunk, _compactionDepth: currentDepth + 1 });
|
|
300
|
-
resolve(continuation);
|
|
301
|
-
}
|
|
302
|
-
catch (err) {
|
|
303
|
-
logger.error(`[${tab.name}] Compaction failed:`, err);
|
|
304
|
-
resolve(result); // Fall back to original result so the user gets *something*.
|
|
305
|
-
}
|
|
306
|
-
})();
|
|
307
|
-
return; // Don't resolve here — the async compaction flow resolves.
|
|
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;
|
|
308
308
|
}
|
|
309
309
|
// Check for delegation completion
|
|
310
|
-
|
|
310
|
+
try {
|
|
311
311
|
const delegation = completeDelegation(tab.name, result.text);
|
|
312
312
|
if (delegation && delegation.returnToTab) {
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
});
|
|
315
318
|
this.onNotify?.(`Delegation complete: ${tab.name} → result sent back to ${delegation.returnToTab}`).catch(() => { });
|
|
316
319
|
logger.info(`Delegation result sent: ${tab.name} → ${delegation.returnToTab}`);
|
|
317
320
|
}
|
|
318
|
-
}
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
319
323
|
logger.warn('Delegation completion check failed:', err);
|
|
320
|
-
}
|
|
324
|
+
}
|
|
321
325
|
resolve(result);
|
|
322
326
|
// Process next queued message (prepend loop warning if needed)
|
|
323
|
-
if (loopWarningPending
|
|
324
|
-
const next = this.
|
|
325
|
-
next
|
|
326
|
-
|
|
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
|
+
}
|
|
327
333
|
}
|
|
328
334
|
this.processNextInQueue(tab.name);
|
|
329
335
|
},
|
|
330
336
|
onError: (err) => {
|
|
337
|
+
errored = true;
|
|
331
338
|
this.subprocesses.delete(tab.name);
|
|
332
|
-
this.circuitBreakers.delete(tab.name);
|
|
333
339
|
this.updateTabStatus(tab.name, 'error');
|
|
334
|
-
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
|
+
});
|
|
335
344
|
reject(err);
|
|
336
345
|
this.processNextInQueue(tab.name);
|
|
337
346
|
},
|
|
338
347
|
};
|
|
339
|
-
subprocess.send(enrichedPrompt, callbacks, shouldResume).catch(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
+
});
|
|
344
357
|
});
|
|
345
358
|
}
|
|
346
359
|
processNextInQueue(tabName) {
|
|
347
|
-
const
|
|
348
|
-
if (!
|
|
360
|
+
const next = this.queue.dequeue(tabName);
|
|
361
|
+
if (!next)
|
|
349
362
|
return;
|
|
350
|
-
const next = queue.shift();
|
|
351
363
|
const tab = this.getTab(tabName);
|
|
352
364
|
if (!tab) {
|
|
353
365
|
next.reject(new Error(`Tab "${tabName}" not found`));
|
|
@@ -356,17 +368,12 @@ export class TabManager {
|
|
|
356
368
|
this.executeMessage(tab, next.prompt, false).then(next.resolve).catch(next.reject);
|
|
357
369
|
}
|
|
358
370
|
updateTabStatus(tabName, status) {
|
|
359
|
-
|
|
360
|
-
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ? WHERE name = ?')
|
|
361
|
-
.run(status, new Date().toISOString(), tabName);
|
|
371
|
+
TabStore.setStatus(tabName, status);
|
|
362
372
|
}
|
|
363
373
|
clearQueue(tabName) {
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
item.reject(new Error(`Tab "${tabName}" was stopped`));
|
|
368
|
-
}
|
|
369
|
-
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`));
|
|
370
377
|
}
|
|
371
378
|
}
|
|
372
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
|
+
}
|
|
@@ -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
|
+
}
|