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.
Files changed (119) hide show
  1. package/dist/capabilities/index.d.ts +1 -1
  2. package/dist/capabilities/index.js +1 -1
  3. package/dist/capabilities/manager.js +13 -9
  4. package/dist/capabilities/packs.js +3 -1
  5. package/dist/channels/command-handler.js +46 -14
  6. package/dist/channels/discord.d.ts +3 -6
  7. package/dist/channels/discord.js +40 -23
  8. package/dist/channels/index.d.ts +1 -1
  9. package/dist/channels/loader.js +13 -3
  10. package/dist/channels/pipeline.js +14 -5
  11. package/dist/channels/registry.d.ts +17 -1
  12. package/dist/channels/registry.js +33 -4
  13. package/dist/channels/telegram.d.ts +20 -5
  14. package/dist/channels/telegram.js +177 -42
  15. package/dist/channels/types.d.ts +11 -28
  16. package/dist/channels/voice-state.js +3 -1
  17. package/dist/channels/webhook.d.ts +1 -4
  18. package/dist/channels/webhook.js +26 -11
  19. package/dist/channels/whatsapp.d.ts +8 -4
  20. package/dist/channels/whatsapp.js +65 -29
  21. package/dist/cli/capabilities.js +4 -4
  22. package/dist/cli/channel.js +16 -6
  23. package/dist/cli/commands.js +12 -9
  24. package/dist/cli/doctor.js +80 -25
  25. package/dist/cli/handoff.d.ts +7 -14
  26. package/dist/cli/handoff.js +9 -44
  27. package/dist/cli/mcp.js +5 -5
  28. package/dist/cli/media.js +21 -8
  29. package/dist/cli/setup.js +9 -8
  30. package/dist/cli/store.js +29 -12
  31. package/dist/config.js +5 -10
  32. package/dist/daemon.js +88 -38
  33. package/dist/dashboard/html.js +80 -12
  34. package/dist/dashboard/routes.js +143 -79
  35. package/dist/dashboard/server.js +5 -1
  36. package/dist/db/connection.d.ts +29 -0
  37. package/dist/db/connection.js +37 -0
  38. package/dist/db/index.js +30 -12
  39. package/dist/db/migrations.js +84 -28
  40. package/dist/delegation/manager.js +10 -4
  41. package/dist/index.js +39 -59
  42. package/dist/knowledge/manager.js +26 -12
  43. package/dist/mcp/handlers.js +126 -57
  44. package/dist/mcp/server.js +20 -10
  45. package/dist/mcp/tool-definitions.js +68 -20
  46. package/dist/mcp/validate.d.ts +23 -0
  47. package/dist/mcp/validate.js +65 -0
  48. package/dist/media/factory.js +18 -14
  49. package/dist/media/generators/dall-e.js +2 -2
  50. package/dist/media/generators/kling.js +4 -4
  51. package/dist/media/generators/lyria.js +1 -1
  52. package/dist/media/generators/nano-banana.d.ts +1 -1
  53. package/dist/media/generators/nano-banana.js +2 -2
  54. package/dist/media/generators/poll-util.js +4 -4
  55. package/dist/media/generators/recraft.js +3 -3
  56. package/dist/media/generators/runway.js +4 -4
  57. package/dist/media/generators/stable-diffusion.js +2 -2
  58. package/dist/media/generators/veo.js +1 -1
  59. package/dist/media/index.js +1 -1
  60. package/dist/media/store.d.ts +7 -0
  61. package/dist/media/store.js +18 -4
  62. package/dist/media/types.d.ts +22 -0
  63. package/dist/notifications/index.d.ts +2 -4
  64. package/dist/notifications/index.js +6 -19
  65. package/dist/notifications/ntfy.js +3 -3
  66. package/dist/observability/analytics.js +35 -13
  67. package/dist/projects/index.d.ts +1 -1
  68. package/dist/projects/index.js +1 -1
  69. package/dist/projects/manager.d.ts +0 -4
  70. package/dist/projects/manager.js +51 -28
  71. package/dist/projects/router.d.ts +2 -0
  72. package/dist/projects/router.js +70 -45
  73. package/dist/service/install.js +15 -5
  74. package/dist/service/windows.js +1 -1
  75. package/dist/session/budget-guard.d.ts +20 -0
  76. package/dist/session/budget-guard.js +31 -0
  77. package/dist/session/circuit-breaker.d.ts +5 -3
  78. package/dist/session/circuit-breaker.js +45 -20
  79. package/dist/session/context-compactor.d.ts +32 -0
  80. package/dist/session/context-compactor.js +45 -0
  81. package/dist/session/context-monitor.js +2 -2
  82. package/dist/session/handoff.d.ts +21 -0
  83. package/dist/session/handoff.js +50 -0
  84. package/dist/session/manager.d.ts +17 -5
  85. package/dist/session/manager.js +153 -146
  86. package/dist/session/memory-store.d.ts +29 -0
  87. package/dist/session/memory-store.js +45 -0
  88. package/dist/session/message-queue.d.ts +28 -0
  89. package/dist/session/message-queue.js +52 -0
  90. package/dist/session/pending-dispatcher.d.ts +31 -0
  91. package/dist/session/pending-dispatcher.js +120 -0
  92. package/dist/session/pending-store.d.ts +60 -0
  93. package/dist/session/pending-store.js +118 -0
  94. package/dist/session/stale-session.d.ts +31 -0
  95. package/dist/session/stale-session.js +45 -0
  96. package/dist/session/subprocess.d.ts +2 -0
  97. package/dist/session/subprocess.js +33 -11
  98. package/dist/session/tab-store.js +4 -3
  99. package/dist/tasks/scheduler.d.ts +7 -0
  100. package/dist/tasks/scheduler.js +46 -6
  101. package/dist/tasks/store.js +20 -6
  102. package/dist/timeline/logger.js +3 -1
  103. package/dist/timeline/query.js +9 -3
  104. package/dist/types.d.ts +34 -9
  105. package/dist/util/auto-heal.js +15 -5
  106. package/dist/util/install-info.js +3 -1
  107. package/dist/util/logger.d.ts +1 -1
  108. package/dist/util/logger.js +63 -24
  109. package/dist/util/paths.d.ts +1 -0
  110. package/dist/util/paths.js +12 -2
  111. package/dist/util/retry.js +1 -1
  112. package/dist/util/text.js +13 -7
  113. package/dist/voice/index.js +5 -1
  114. package/dist/voice/stt.js +14 -6
  115. package/dist/voice/tts.js +1 -1
  116. package/dist/watchers/scheduler.js +9 -2
  117. package/package.json +6 -1
  118. package/dist/session/tool-classifier.d.ts +0 -4
  119. package/dist/session/tool-classifier.js +0 -56
@@ -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
- const MAX_QUEUE_SIZE = 10;
12
- 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
+ */
13
30
  export class TabManager {
14
31
  config;
15
32
  subprocesses = new Map();
16
- circuitBreakers = new Map();
17
- messageQueues = new Map();
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 = this.queryTab(db, tabName);
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
- if (!this.messageQueues.has(tabName)) {
69
- this.messageQueues.set(tabName, []);
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
- this.messageQueues.get(tabName).push({ prompt, resolve, reject });
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.circuitBreakers.clear();
116
- this.messageQueues.clear();
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', { tabName: tab.name, details: prompt.slice(0, 500) });
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
- const tabSpend = db.prepare('SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE tab_id = ?').get(tab.id).total;
156
- if (tabSpend >= this.config.claudeCode.maxBudgetUsd) {
157
- const msg = `Budget limit reached for tab "${tab.name}": $${tabSpend.toFixed(2)} / $${this.config.claudeCode.maxBudgetUsd.toFixed(2)}`;
158
- this.onNotify?.(msg).catch(() => { });
159
- return { text: msg, costUsd: 0, durationMs: 0, sessionId: tab.sessionId, error: true };
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
- // Warn at 80%
162
- if (tabSpend >= this.config.claudeCode.maxBudgetUsd * 0.8) {
163
- this.onNotify?.(`⚠️ Budget warning: tab "${tab.name}" at $${tabSpend.toFixed(2)} / $${this.config.claudeCode.maxBudgetUsd.toFixed(2)} (80%)`).catch(() => { });
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 = this.queryTab(db, tab.name) || tab;
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
- const hasDbHistory = db.prepare('SELECT COUNT(*) as count FROM messages WHERE tab_id = ? AND role = ? AND content != ?').get(tab.id, 'assistant', '');
184
- const shouldResume = !forceFresh && (resume || hasDbHistory.count > 0);
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 ?? (code !== 0),
263
+ error: resultEvent?.is_error ?? code !== 0,
249
264
  };
250
265
  // Handle resume failure (Claude Code session cache rotated / never existed / expired).
251
- // Detection covers both legacy text-based errors and the modern error_during_execution
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 recentMsgs = db.prepare("SELECT role, content FROM messages WHERE tab_id = ? AND content != '' ORDER BY created_at DESC LIMIT 5").all(tab.id);
261
- const context = recentMsgs.reverse().map(m => `${m.role}: ${m.content.slice(0, 200)}`).join('\n');
262
- const contextPrompt = context
263
- ? `[Previous conversation context:\n${context}\n]\n\n${enrichedPrompt}`
264
- : enrichedPrompt;
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
- .run('idle', new Date().toISOString(), tab.name);
282
- logActivity('task_completed', 'Message processed', { tabName: tab.name, durationMs: result.durationMs, costUsd: result.costUsd, details: result.text.slice(0, 500) });
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 && !result.error && result.text && currentDepth < 2) {
286
- logger.info(`[${tab.name}] Compacting context (depth ${currentDepth + 1}/2) — requesting summary then restarting session`);
287
- this.onNotify?.(`🔄 [${tab.name}] Context window full — compacting and continuing...`).catch(err => logger.warn('Notify failed:', err));
288
- // Async/await so any thrown error (from DB or sendMessage) routes through
289
- // a single catch instead of the previous nested .then().catch() chain
290
- // where a sync throw inside the inner .then could orphan the promise.
291
- (async () => {
292
- try {
293
- const summaryPrompt = 'Summarize your progress in this session concisely: completed steps, current state, remaining steps, and all important identifiers (file paths, URLs, variable names). Output ONLY the summary.';
294
- const summaryResult = await this.sendMessage(tab.name, summaryPrompt, { _compactionDepth: currentDepth + 1 });
295
- const newSessionId = uuidv4();
296
- db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(newSessionId, tab.id);
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
- import('../delegation/manager.js').then(({ completeDelegation }) => {
310
+ try {
311
311
  const delegation = completeDelegation(tab.name, result.text);
312
312
  if (delegation && delegation.returnToTab) {
313
- // Queue result message back to the source tab
314
- db.prepare('INSERT INTO pending_messages (tab_name, message, type) VALUES (?, ?, ?)').run(delegation.returnToTab, `[Result from tab:${tab.name}]: ${result.text.slice(0, 10000)}`, 'delegation_result');
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
- }).catch(err => {
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 && this.messageQueues.get(tab.name)?.length) {
324
- const next = this.messageQueues.get(tab.name)[0];
325
- next.prompt = `[WARNING: You appear to be repeating the same action. Reassess your approach.]\n\n${next.prompt}`;
326
- loopWarningPending = false;
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', { tabName: tab.name, details: err instanceof Error ? err.message : String(err) });
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(reject);
340
- // Update tab with PID
341
- if (subprocess.pid) {
342
- db.prepare('UPDATE tabs SET pid = ? WHERE name = ?').run(subprocess.pid, tab.name);
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 queue = this.messageQueues.get(tabName);
348
- if (!queue || queue.length === 0)
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
- const db = getDb();
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 queue = this.messageQueues.get(tabName);
365
- if (queue) {
366
- for (const item of queue) {
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
+ }