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.
Files changed (138) 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/admin.d.ts +10 -0
  6. package/dist/channels/admin.js +20 -0
  7. package/dist/channels/command-handler.d.ts +2 -10
  8. package/dist/channels/command-handler.js +90 -84
  9. package/dist/channels/discord.d.ts +4 -9
  10. package/dist/channels/discord.js +59 -42
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/loader.js +13 -4
  13. package/dist/channels/pipeline.js +14 -5
  14. package/dist/channels/registry.d.ts +17 -1
  15. package/dist/channels/registry.js +33 -4
  16. package/dist/channels/send-helpers.d.ts +19 -0
  17. package/dist/channels/send-helpers.js +21 -0
  18. package/dist/channels/telegram.d.ts +21 -14
  19. package/dist/channels/telegram.js +214 -104
  20. package/dist/channels/types.d.ts +13 -38
  21. package/dist/channels/voice-state.d.ts +29 -0
  22. package/dist/channels/voice-state.js +45 -0
  23. package/dist/channels/webhook.d.ts +2 -5
  24. package/dist/channels/webhook.js +88 -29
  25. package/dist/channels/whatsapp.d.ts +9 -7
  26. package/dist/channels/whatsapp.js +141 -100
  27. package/dist/cli/capabilities.js +4 -4
  28. package/dist/cli/channel.js +16 -6
  29. package/dist/cli/commands.js +12 -9
  30. package/dist/cli/doctor.js +85 -27
  31. package/dist/cli/handoff.d.ts +7 -14
  32. package/dist/cli/handoff.js +9 -44
  33. package/dist/cli/mcp.js +5 -5
  34. package/dist/cli/media.js +21 -8
  35. package/dist/cli/setup.js +9 -8
  36. package/dist/cli/store.js +29 -12
  37. package/dist/config.d.ts +5 -1
  38. package/dist/config.js +20 -22
  39. package/dist/daemon.js +113 -51
  40. package/dist/dashboard/html.js +100 -20
  41. package/dist/dashboard/routes.d.ts +17 -0
  42. package/dist/dashboard/routes.js +623 -0
  43. package/dist/dashboard/server.js +38 -489
  44. package/dist/db/connection.d.ts +29 -0
  45. package/dist/db/connection.js +37 -0
  46. package/dist/db/index.js +43 -11
  47. package/dist/db/migrations.js +114 -22
  48. package/dist/delegation/manager.js +10 -4
  49. package/dist/index.js +39 -59
  50. package/dist/knowledge/manager.js +26 -12
  51. package/dist/mcp/handlers.d.ts +37 -0
  52. package/dist/mcp/handlers.js +520 -0
  53. package/dist/mcp/server.js +44 -858
  54. package/dist/mcp/tool-definitions.d.ts +1225 -0
  55. package/dist/mcp/tool-definitions.js +412 -0
  56. package/dist/mcp/validate.d.ts +23 -0
  57. package/dist/mcp/validate.js +65 -0
  58. package/dist/media/factory.js +18 -14
  59. package/dist/media/generators/dall-e.js +2 -2
  60. package/dist/media/generators/kling.js +4 -4
  61. package/dist/media/generators/lyria.js +1 -1
  62. package/dist/media/generators/nano-banana.d.ts +1 -1
  63. package/dist/media/generators/nano-banana.js +2 -2
  64. package/dist/media/generators/poll-util.js +4 -4
  65. package/dist/media/generators/recraft.js +3 -3
  66. package/dist/media/generators/runway.js +4 -4
  67. package/dist/media/generators/stable-diffusion.js +2 -2
  68. package/dist/media/generators/veo.js +1 -1
  69. package/dist/media/index.d.ts +2 -7
  70. package/dist/media/index.js +2 -2
  71. package/dist/media/store.d.ts +7 -0
  72. package/dist/media/store.js +18 -4
  73. package/dist/media/types.d.ts +22 -0
  74. package/dist/notifications/index.d.ts +2 -4
  75. package/dist/notifications/index.js +6 -19
  76. package/dist/notifications/ntfy.js +3 -3
  77. package/dist/observability/analytics.d.ts +1 -1
  78. package/dist/observability/analytics.js +41 -16
  79. package/dist/projects/index.d.ts +3 -2
  80. package/dist/projects/index.js +2 -2
  81. package/dist/projects/manager.d.ts +1 -7
  82. package/dist/projects/manager.js +66 -42
  83. package/dist/projects/router.d.ts +12 -0
  84. package/dist/projects/router.js +98 -45
  85. package/dist/service/install.js +15 -5
  86. package/dist/service/windows.js +1 -1
  87. package/dist/session/budget-guard.d.ts +20 -0
  88. package/dist/session/budget-guard.js +31 -0
  89. package/dist/session/circuit-breaker.d.ts +5 -3
  90. package/dist/session/circuit-breaker.js +45 -20
  91. package/dist/session/context-compactor.d.ts +32 -0
  92. package/dist/session/context-compactor.js +45 -0
  93. package/dist/session/context-monitor.js +2 -2
  94. package/dist/session/handoff.d.ts +21 -0
  95. package/dist/session/handoff.js +50 -0
  96. package/dist/session/manager.d.ts +21 -5
  97. package/dist/session/manager.js +166 -153
  98. package/dist/session/memory-store.d.ts +29 -0
  99. package/dist/session/memory-store.js +45 -0
  100. package/dist/session/message-queue.d.ts +28 -0
  101. package/dist/session/message-queue.js +52 -0
  102. package/dist/session/pending-dispatcher.d.ts +31 -0
  103. package/dist/session/pending-dispatcher.js +120 -0
  104. package/dist/session/pending-store.d.ts +60 -0
  105. package/dist/session/pending-store.js +118 -0
  106. package/dist/session/stale-session.d.ts +31 -0
  107. package/dist/session/stale-session.js +45 -0
  108. package/dist/session/subprocess.d.ts +3 -0
  109. package/dist/session/subprocess.js +54 -11
  110. package/dist/session/tab-store.d.ts +28 -0
  111. package/dist/session/tab-store.js +78 -0
  112. package/dist/tasks/scheduler.d.ts +13 -0
  113. package/dist/tasks/scheduler.js +97 -18
  114. package/dist/tasks/store.js +26 -12
  115. package/dist/timeline/logger.js +3 -1
  116. package/dist/timeline/query.js +15 -5
  117. package/dist/types.d.ts +49 -9
  118. package/dist/util/auto-heal.js +15 -5
  119. package/dist/util/install-info.js +3 -1
  120. package/dist/util/logger.d.ts +1 -1
  121. package/dist/util/logger.js +63 -24
  122. package/dist/util/paths.d.ts +2 -0
  123. package/dist/util/paths.js +16 -3
  124. package/dist/util/rate-limiter.js +8 -0
  125. package/dist/util/retry.js +1 -1
  126. package/dist/util/text.d.ts +21 -1
  127. package/dist/util/text.js +38 -8
  128. package/dist/voice/index.js +5 -1
  129. package/dist/voice/stt.js +14 -6
  130. package/dist/voice/tts.js +1 -1
  131. package/dist/watchers/scheduler.js +11 -5
  132. package/package.json +6 -1
  133. package/dist/session/tool-classifier.d.ts +0 -4
  134. package/dist/session/tool-classifier.js +0 -56
  135. package/dist/users/index.d.ts +0 -2
  136. package/dist/users/index.js +0 -1
  137. package/dist/users/service.d.ts +0 -17
  138. 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 circuitBreakers;
14
- private messageQueues;
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;
@@ -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
- function rowToTab(row) {
11
- return {
12
- id: row.id,
13
- name: row.name,
14
- sessionId: row.session_id,
15
- status: row.status,
16
- workingDir: row.working_dir,
17
- createdAt: row.created_at,
18
- lastActivityAt: row.last_activity_at,
19
- pid: row.pid,
20
- systemPrompt: row.system_prompt,
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
- circuitBreakers = new Map();
29
- 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();
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 = this.queryTab(db, tabName);
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
- if (!this.messageQueues.has(tabName)) {
81
- 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;
82
92
  }
83
- this.messageQueues.get(tabName).push({ prompt, resolve, reject });
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
- const db = getDb();
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
- const db = getDb();
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.circuitBreakers.clear();
122
- this.messageQueues.clear();
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', { 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
+ });
152
146
  // Budget check before spawning
153
147
  if (this.config.claudeCode.maxBudgetUsd) {
154
- const tabSpend = db.prepare('SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE tab_id = ?').get(tab.id).total;
155
- if (tabSpend >= this.config.claudeCode.maxBudgetUsd) {
156
- const msg = `Budget limit reached for tab "${tab.name}": $${tabSpend.toFixed(2)} / $${this.config.claudeCode.maxBudgetUsd.toFixed(2)}`;
157
- this.onNotify?.(msg).catch(() => { });
158
- 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);
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
- // Warn at 80%
161
- if (tabSpend >= this.config.claudeCode.maxBudgetUsd * 0.8) {
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 = this.queryTab(db, tab.name) || tab;
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
- const hasDbHistory = db.prepare('SELECT COUNT(*) as count FROM messages WHERE tab_id = ? AND role = ? AND content != ?').get(tab.id, 'assistant', '');
183
- 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);
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 ?? (code !== 0),
263
+ error: resultEvent?.is_error ?? code !== 0,
248
264
  };
249
265
  // Handle resume failure (Claude Code session cache rotated / never existed / expired).
250
- // Detection covers both legacy text-based errors and the modern error_during_execution
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 recentMsgs = db.prepare("SELECT role, content FROM messages WHERE tab_id = ? AND content != '' ORDER BY created_at DESC LIMIT 5").all(tab.id);
260
- const context = recentMsgs.reverse().map(m => `${m.role}: ${m.content.slice(0, 200)}`).join('\n');
261
- const contextPrompt = context
262
- ? `[Previous conversation context:\n${context}\n]\n\n${enrichedPrompt}`
263
- : enrichedPrompt;
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
- .run('idle', new Date().toISOString(), tab.name);
281
- logActivity('task_completed', 'Message processed', { tabName: tab.name, durationMs: result.durationMs, costUsd: result.costUsd, details: result.text.slice(0, 500) });
282
- // Context window compaction: if checkpoint was triggered, restart with summary
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 && !result.error && result.text && currentDepth < 2) {
285
- logger.info(`[${tab.name}] Compacting context (depth ${currentDepth + 1}/2) — requesting summary then restarting session`);
286
- this.onNotify?.(`🔄 [${tab.name}] Context window full — compacting and continuing...`).catch(err => logger.warn('Notify failed:', err));
287
- // Ask Claude for a structured summary
288
- 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.';
289
- this.sendMessage(tab.name, summaryPrompt, { _compactionDepth: currentDepth + 1 }).then(summaryResult => {
290
- // Reset session: new session ID so next message starts fresh with summary context
291
- const newSessionId = uuidv4();
292
- db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(newSessionId, tab.id);
293
- logger.info(`[${tab.name}] Context compacted — new session ${newSessionId.slice(0, 8)}...`);
294
- // Continue with original goal using the summary as in-prompt context (not persisted)
295
- const continuationPrompt = `[CONTEXT RESTORED FROM PREVIOUS SESSION]\n${summaryResult.text}\n\n[Continue the original task: "${enrichedPrompt.slice(0, 500)}"]`;
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
- import('../delegation/manager.js').then(({ completeDelegation }) => {
310
+ try {
305
311
  const delegation = completeDelegation(tab.name, result.text);
306
312
  if (delegation && delegation.returnToTab) {
307
- // Queue result message back to the source tab
308
- 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
+ });
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
- }).catch(err => {
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 && this.messageQueues.get(tab.name)?.length) {
318
- const next = this.messageQueues.get(tab.name)[0];
319
- next.prompt = `[WARNING: You appear to be repeating the same action. Reassess your approach.]\n\n${next.prompt}`;
320
- 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
+ }
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', { 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
+ });
329
344
  reject(err);
330
345
  this.processNextInQueue(tab.name);
331
346
  },
332
347
  };
333
- subprocess.send(enrichedPrompt, callbacks, shouldResume).catch(reject);
334
- // Update tab with PID
335
- if (subprocess.pid) {
336
- db.prepare('UPDATE tabs SET pid = ? WHERE name = ?').run(subprocess.pid, tab.name);
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 queue = this.messageQueues.get(tabName);
342
- if (!queue || queue.length === 0)
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
- const db = getDb();
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 queue = this.messageQueues.get(tabName);
359
- if (queue) {
360
- for (const item of queue) {
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
+ }