@luckydraw/cumulus 0.27.41 → 0.28.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 (99) hide show
  1. package/dist/gateway/adapters/webchat.d.ts +5 -7
  2. package/dist/gateway/adapters/webchat.d.ts.map +1 -1
  3. package/dist/gateway/adapters/webchat.js +204 -57
  4. package/dist/gateway/adapters/webchat.js.map +1 -1
  5. package/dist/gateway/config.d.ts +48 -0
  6. package/dist/gateway/config.d.ts.map +1 -1
  7. package/dist/gateway/config.js +90 -0
  8. package/dist/gateway/config.js.map +1 -1
  9. package/dist/gateway/daemon.d.ts.map +1 -1
  10. package/dist/gateway/daemon.js +268 -27
  11. package/dist/gateway/daemon.js.map +1 -1
  12. package/dist/gateway/gateway-agents-mcp.js +570 -31
  13. package/dist/gateway/gateway-agents-mcp.js.map +1 -1
  14. package/dist/gateway/hooks.d.ts +101 -0
  15. package/dist/gateway/hooks.d.ts.map +1 -0
  16. package/dist/gateway/hooks.js +200 -0
  17. package/dist/gateway/hooks.js.map +1 -0
  18. package/dist/gateway/scheduler.d.ts +50 -0
  19. package/dist/gateway/scheduler.d.ts.map +1 -0
  20. package/dist/gateway/scheduler.js +263 -0
  21. package/dist/gateway/scheduler.js.map +1 -0
  22. package/dist/gateway/server.d.ts +16 -1
  23. package/dist/gateway/server.d.ts.map +1 -1
  24. package/dist/gateway/server.js +451 -22
  25. package/dist/gateway/server.js.map +1 -1
  26. package/dist/gateway/static/blex.min.js +74 -17
  27. package/dist/gateway/static/chat.html +1 -4
  28. package/dist/gateway/static/dashboard.html +151 -0
  29. package/dist/gateway/static/dashboard.js +229 -0
  30. package/dist/gateway/static/widget.js +656 -460
  31. package/dist/index.d.ts +13 -5
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +9 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/agentic-loop.d.ts +124 -0
  36. package/dist/lib/agentic-loop.d.ts.map +1 -0
  37. package/dist/lib/agentic-loop.js +338 -0
  38. package/dist/lib/agentic-loop.js.map +1 -0
  39. package/dist/lib/config.d.ts +28 -15
  40. package/dist/lib/config.d.ts.map +1 -1
  41. package/dist/lib/config.js +47 -41
  42. package/dist/lib/config.js.map +1 -1
  43. package/dist/lib/gateway.d.ts +34 -3
  44. package/dist/lib/gateway.d.ts.map +1 -1
  45. package/dist/lib/gateway.js +584 -37
  46. package/dist/lib/gateway.js.map +1 -1
  47. package/dist/lib/huggingface-provider.d.ts +26 -0
  48. package/dist/lib/huggingface-provider.d.ts.map +1 -0
  49. package/dist/lib/huggingface-provider.js +338 -0
  50. package/dist/lib/huggingface-provider.js.map +1 -0
  51. package/dist/lib/provider.d.ts +71 -0
  52. package/dist/lib/provider.d.ts.map +1 -0
  53. package/dist/lib/provider.js +90 -0
  54. package/dist/lib/provider.js.map +1 -0
  55. package/dist/lib/tools/ask-user-question.d.ts +14 -0
  56. package/dist/lib/tools/ask-user-question.d.ts.map +1 -0
  57. package/dist/lib/tools/ask-user-question.js +74 -0
  58. package/dist/lib/tools/ask-user-question.js.map +1 -0
  59. package/dist/lib/tools/bash.d.ts +6 -0
  60. package/dist/lib/tools/bash.d.ts.map +1 -0
  61. package/dist/lib/tools/bash.js +85 -0
  62. package/dist/lib/tools/bash.js.map +1 -0
  63. package/dist/lib/tools/edit.d.ts +6 -0
  64. package/dist/lib/tools/edit.d.ts.map +1 -0
  65. package/dist/lib/tools/edit.js +79 -0
  66. package/dist/lib/tools/edit.js.map +1 -0
  67. package/dist/lib/tools/glob.d.ts +6 -0
  68. package/dist/lib/tools/glob.d.ts.map +1 -0
  69. package/dist/lib/tools/glob.js +91 -0
  70. package/dist/lib/tools/glob.js.map +1 -0
  71. package/dist/lib/tools/grep.d.ts +6 -0
  72. package/dist/lib/tools/grep.d.ts.map +1 -0
  73. package/dist/lib/tools/grep.js +110 -0
  74. package/dist/lib/tools/grep.js.map +1 -0
  75. package/dist/lib/tools/index.d.ts +29 -0
  76. package/dist/lib/tools/index.d.ts.map +1 -0
  77. package/dist/lib/tools/index.js +52 -0
  78. package/dist/lib/tools/index.js.map +1 -0
  79. package/dist/lib/tools/read.d.ts +6 -0
  80. package/dist/lib/tools/read.d.ts.map +1 -0
  81. package/dist/lib/tools/read.js +80 -0
  82. package/dist/lib/tools/read.js.map +1 -0
  83. package/dist/lib/tools/tool-search.d.ts +30 -0
  84. package/dist/lib/tools/tool-search.d.ts.map +1 -0
  85. package/dist/lib/tools/tool-search.js +108 -0
  86. package/dist/lib/tools/tool-search.js.map +1 -0
  87. package/dist/lib/tools/web-fetch.d.ts +9 -0
  88. package/dist/lib/tools/web-fetch.d.ts.map +1 -0
  89. package/dist/lib/tools/web-fetch.js +114 -0
  90. package/dist/lib/tools/web-fetch.js.map +1 -0
  91. package/dist/lib/tools/web-search.d.ts +12 -0
  92. package/dist/lib/tools/web-search.d.ts.map +1 -0
  93. package/dist/lib/tools/web-search.js +89 -0
  94. package/dist/lib/tools/web-search.js.map +1 -0
  95. package/dist/lib/tools/write.d.ts +6 -0
  96. package/dist/lib/tools/write.d.ts.map +1 -0
  97. package/dist/lib/tools/write.js +44 -0
  98. package/dist/lib/tools/write.js.map +1 -0
  99. package/package.json +3 -2
@@ -10,27 +10,64 @@ import { randomUUID } from 'crypto';
10
10
  import * as fs from 'fs';
11
11
  import * as os from 'os';
12
12
  import * as path from 'path';
13
+ import { getToolDefinitions as getMcpToolDefinitions, handleToolCall, } from '../mcp/tool-handler.js';
13
14
  import { loadAdaptiveState } from './adaptive-context-budget.js';
14
- import { loadGlobalConfig, loadThreadConfig, mergeConfigs, readAlwaysIncludeFiles, resolveRagBudget, } from './config.js';
15
+ import { runAgenticLoop } from './agentic-loop.js';
16
+ import { loadGlobalConfig, loadThreadConfig, mergeConfigs, readAlwaysIncludeFiles, } from './config.js';
15
17
  import { saveThreadConfig } from './config.js';
16
18
  import { ContentStore } from './content-store.js';
17
19
  import { ContextBudget, estimateTokens } from './context-budget.js';
18
20
  import { HistoryStore } from './history.js';
21
+ import { HuggingFaceProvider, getModelContextLimit, getModelMaxOutput, } from './huggingface-provider.js';
19
22
  import { retrieve } from './retriever.js';
20
23
  import { SessionManager } from './session.js';
21
24
  import { StreamProcessor, shouldExternalizeUserInput, externalizeUserInput, } from './stream-processor.js';
22
25
  import { scaffoldFromTemplate } from './templates.js';
26
+ import { getEagerTools } from './tools/index.js';
23
27
  // ─── Constants ───────────────────────────────────────────────────────────────
24
28
  const CUMULUS_DIR = path.join(os.homedir(), '.cumulus');
25
29
  const THREADS_DIR = path.join(CUMULUS_DIR, 'threads');
30
+ // ─── Active subprocess tracking (for graceful shutdown) ──────────────────────
31
+ const _activeSubprocesses = new Set();
32
+ /** Promises that resolve when each subprocess has fully persisted its response */
33
+ const _completionPromises = new Map();
34
+ /** Map subprocess → thread name (for auto-resume after restart) */
35
+ const _activeThreadNames = new Map();
36
+ /** Read-only view of active Claude subprocesses (for health/shutdown) */
37
+ export function getActiveSubprocessCount() {
38
+ return _activeSubprocesses.size;
39
+ }
40
+ /** Register a completion promise for a subprocess (resolves after persistence) */
41
+ export function registerCompletionPromise(child, promise) {
42
+ _completionPromises.set(child, promise);
43
+ }
44
+ /** Get thread names of all currently active (in-flight) subprocesses */
45
+ export function getActiveThreadNames() {
46
+ return Array.from(_activeThreadNames.values());
47
+ }
48
+ /** Wait for all active subprocesses to complete AND persist their responses (with timeout) */
49
+ export async function drainActiveSubprocesses(timeoutMs = 120_000) {
50
+ const promises = Array.from(_completionPromises.values());
51
+ if (promises.length === 0 && _activeSubprocesses.size === 0)
52
+ return 0;
53
+ const startCount = Math.max(_activeSubprocesses.size, promises.length);
54
+ // Wait for all completion promises (which include persistence) with timeout
55
+ const timeout = new Promise(resolve => setTimeout(resolve, timeoutMs));
56
+ await Promise.race([Promise.allSettled(promises), timeout]);
57
+ // Force-kill any remaining after timeout
58
+ for (const child of _activeSubprocesses) {
59
+ child.kill('SIGTERM');
60
+ }
61
+ return startCount - _activeSubprocesses.size;
62
+ }
26
63
  export const RECENT_CONTEXT_COUNT = 10;
27
64
  export const RECENT_MSG_MAX_TOKENS = 500;
28
65
  export const RECENT_CONTEXT_BUDGET = 6_000;
29
66
  export const SYSTEM_PROMPT_TEMPLATE = `You have NO memory of this conversation. There are {count} prior messages (~{tokens} tokens) in the history.
30
67
 
31
68
  CURRENT SESSION: {sessionId}
32
- {alwaysIncludeContext}{recentContext}
33
- {retrievedContext}
69
+ {alwaysIncludeContext}{retrievedContext}
70
+ {recentContext}
34
71
  CONTEXT MANAGEMENT:
35
72
  - RECENT CONVERSATION: The last few messages, always included for continuity.
36
73
  - RETRIEVED CONTEXT: Automatically retrieved based on the user's current message using semantic + keyword search.
@@ -105,6 +142,146 @@ function getContentStorePath(threadPath) {
105
142
  function getSessionsPath(threadPath) {
106
143
  return threadPath.replace(/\.jsonl$/, '.sessions');
107
144
  }
145
+ function getStreamingTmpPath(threadName) {
146
+ return path.join(THREADS_DIR, `${threadName}.streaming.tmp`);
147
+ }
148
+ /**
149
+ * Create a write-through streaming buffer for a thread.
150
+ * Writes partial response to disk every 5 seconds so it survives restarts.
151
+ */
152
+ function createStreamingBuffer(threadName, userMessageId) {
153
+ const tmpPath = getStreamingTmpPath(threadName);
154
+ const header = JSON.stringify({
155
+ threadName,
156
+ startedAt: new Date().toISOString(),
157
+ userMessageId: userMessageId || '',
158
+ });
159
+ try {
160
+ fs.writeFileSync(tmpPath, header + '\n---\n', 'utf-8');
161
+ }
162
+ catch (err) {
163
+ console.error(`[streaming-buffer] Failed to create ${tmpPath}:`, err);
164
+ }
165
+ return { tmpPath, flushedLength: 0, interval: null };
166
+ }
167
+ /**
168
+ * Flush accumulated response text to the streaming tmp file.
169
+ */
170
+ function flushStreamingBuffer(state, fullResponse) {
171
+ if (fullResponse.length <= state.flushedLength)
172
+ return;
173
+ const newText = fullResponse.slice(state.flushedLength);
174
+ try {
175
+ fs.appendFileSync(state.tmpPath, newText, 'utf-8');
176
+ state.flushedLength = fullResponse.length;
177
+ }
178
+ catch (err) {
179
+ console.error(`[streaming-buffer] Flush failed for ${state.tmpPath}:`, err);
180
+ }
181
+ }
182
+ /**
183
+ * Clean up the streaming tmp file (called on normal completion).
184
+ */
185
+ function cleanupStreamingBuffer(state) {
186
+ if (state.interval) {
187
+ clearInterval(state.interval);
188
+ state.interval = null;
189
+ }
190
+ try {
191
+ if (fs.existsSync(state.tmpPath)) {
192
+ fs.unlinkSync(state.tmpPath);
193
+ }
194
+ }
195
+ catch {
196
+ // Non-critical — stale tmp files are handled by recovery on next startup
197
+ }
198
+ }
199
+ /**
200
+ * Start periodic flushing (every 5 seconds).
201
+ */
202
+ function startStreamingFlush(state, getFullResponse) {
203
+ state.interval = setInterval(() => {
204
+ flushStreamingBuffer(state, getFullResponse());
205
+ }, 5_000);
206
+ }
207
+ /**
208
+ * Recover partial responses from .streaming.tmp files left by a previous crash/restart.
209
+ * Called on daemon startup BEFORE auto-resume.
210
+ * Returns the number of partial responses recovered.
211
+ */
212
+ export async function recoverStreamingBuffers() {
213
+ ensureDirectories();
214
+ let recovered = 0;
215
+ let files;
216
+ try {
217
+ files = fs.readdirSync(THREADS_DIR).filter(f => f.endsWith('.streaming.tmp'));
218
+ }
219
+ catch {
220
+ return 0;
221
+ }
222
+ for (const file of files) {
223
+ const tmpPath = path.join(THREADS_DIR, file);
224
+ try {
225
+ const raw = fs.readFileSync(tmpPath, 'utf-8');
226
+ const separatorIdx = raw.indexOf('\n---\n');
227
+ if (separatorIdx === -1) {
228
+ fs.unlinkSync(tmpPath);
229
+ continue;
230
+ }
231
+ const headerStr = raw.slice(0, separatorIdx);
232
+ const responseText = raw.slice(separatorIdx + 5); // skip '\n---\n'
233
+ if (!responseText.trim()) {
234
+ // No actual response content — just delete
235
+ fs.unlinkSync(tmpPath);
236
+ continue;
237
+ }
238
+ let header;
239
+ try {
240
+ header = JSON.parse(headerStr);
241
+ }
242
+ catch {
243
+ fs.unlinkSync(tmpPath);
244
+ continue;
245
+ }
246
+ // Check for stale tmp: if the thread has newer messages, skip
247
+ const threadPath = getThreadPath(header.threadName);
248
+ if (fs.existsSync(threadPath)) {
249
+ const stat = fs.statSync(threadPath);
250
+ const tmpStat = fs.statSync(tmpPath);
251
+ if (stat.mtimeMs > tmpStat.mtimeMs) {
252
+ // Thread was already updated after the tmp was written — skip
253
+ console.error(`[streaming-buffer] Skipping stale tmp for ${header.threadName}`);
254
+ fs.unlinkSync(tmpPath);
255
+ continue;
256
+ }
257
+ }
258
+ // Persist the partial response to JSONL
259
+ const thread = await getOrCreateThread(header.threadName);
260
+ await thread.history.append({
261
+ role: 'assistant',
262
+ content: responseText + '\n\n*(response interrupted by gateway restart)*',
263
+ metadata: {
264
+ partial: true,
265
+ interruptedAt: header.startedAt,
266
+ },
267
+ });
268
+ console.error(`[streaming-buffer] Recovered partial response for ${header.threadName} (${responseText.length} chars)`);
269
+ recovered++;
270
+ fs.unlinkSync(tmpPath);
271
+ }
272
+ catch (err) {
273
+ console.error(`[streaming-buffer] Failed to recover ${file}:`, err);
274
+ // Try to clean up
275
+ try {
276
+ fs.unlinkSync(tmpPath);
277
+ }
278
+ catch {
279
+ /* ignore */
280
+ }
281
+ }
282
+ }
283
+ return recovered;
284
+ }
108
285
  /**
109
286
  * Resolve the full path to the `claude` CLI binary.
110
287
  * When launched from Finder/launchd, PATH is minimal.
@@ -231,15 +408,22 @@ export function generateMcpConfig(threadPath, sessionId, threadName, sharedMcpPo
231
408
  let gatewayAgentsEntry = {};
232
409
  if (gatewayAgentsConfig) {
233
410
  const agentsMcpPath = getGatewayAgentsMcpPathInternal();
411
+ const agentsEnv = {
412
+ GATEWAY_API_URL: gatewayAgentsConfig.apiUrl,
413
+ GATEWAY_API_KEY: gatewayAgentsConfig.apiKey,
414
+ GATEWAY_AGENT_NAME: threadName,
415
+ };
416
+ if (gatewayAgentsConfig.resendApiKey) {
417
+ agentsEnv.RESEND_API_KEY = gatewayAgentsConfig.resendApiKey;
418
+ agentsEnv.RESEND_DEFAULT_FROM = gatewayAgentsConfig.resendDefaultFrom || '';
419
+ agentsEnv.RESEND_REPLY_TO = gatewayAgentsConfig.resendReplyTo || '';
420
+ agentsEnv.RESEND_RATE_LIMIT = String(gatewayAgentsConfig.resendRateLimit || 10);
421
+ }
234
422
  gatewayAgentsEntry = {
235
423
  'gateway-agents': {
236
424
  command: nodePath,
237
425
  args: [agentsMcpPath],
238
- env: {
239
- GATEWAY_API_URL: gatewayAgentsConfig.apiUrl,
240
- GATEWAY_API_KEY: gatewayAgentsConfig.apiKey,
241
- GATEWAY_AGENT_NAME: threadName,
242
- },
426
+ env: agentsEnv,
243
427
  },
244
428
  };
245
429
  }
@@ -408,17 +592,12 @@ export function computeAvgRelevance(debug) {
408
592
  * Get or create a thread's stores and adaptive budget.
409
593
  * Cached in a module-level Map.
410
594
  */
411
- export async function getOrCreateThread(threadName, basePath) {
412
- // Use basePath-qualified cache key to prevent cross-environment collisions
413
- const cacheKey = basePath ? `${basePath}::${threadName}` : threadName;
414
- if (threadCache.has(cacheKey)) {
415
- return threadCache.get(cacheKey);
416
- }
417
- const threadsDir = basePath ? path.join(basePath, 'threads') : THREADS_DIR;
418
- if (!fs.existsSync(threadsDir)) {
419
- fs.mkdirSync(threadsDir, { recursive: true });
595
+ export async function getOrCreateThread(threadName, _basePath) {
596
+ if (threadCache.has(threadName)) {
597
+ return threadCache.get(threadName);
420
598
  }
421
- const threadPath = path.join(threadsDir, `${threadName}.jsonl`);
599
+ ensureDirectories();
600
+ const threadPath = getThreadPath(threadName);
422
601
  const contentStorePath = getContentStorePath(threadPath);
423
602
  const sessionsPath = getSessionsPath(threadPath);
424
603
  const history = new HistoryStore(threadPath);
@@ -427,15 +606,12 @@ export async function getOrCreateThread(threadName, basePath) {
427
606
  await session.initialize();
428
607
  const adaptive = await loadAdaptiveState(threadPath);
429
608
  const thread = { history, content, session, adaptive, threadPath };
430
- threadCache.set(cacheKey, thread);
609
+ threadCache.set(threadName, thread);
431
610
  return thread;
432
611
  }
433
612
  /** Clear a thread from the cache */
434
- export function clearThreadCache(threadName, basePath) {
613
+ export function clearThreadCache(threadName) {
435
614
  if (threadName) {
436
- const cacheKey = basePath ? `${basePath}::${threadName}` : threadName;
437
- threadCache.delete(cacheKey);
438
- // Also try the plain name for backward compat
439
615
  threadCache.delete(threadName);
440
616
  }
441
617
  else {
@@ -450,7 +626,7 @@ export function clearThreadCache(threadName, basePath) {
450
626
  * from both Janus (via IPC) and the gateway server (via HTTP/SSE).
451
627
  */
452
628
  export async function sendMessage(options) {
453
- const { threadName, message, images = [], attachments = [], basePath = CUMULUS_DIR, claudePath, sharedMcpPort, systemPromptTemplate, projectDir, projectRoot, extraMcpServers, gatewayAgentsConfig, onToken, onSegment, onError, onSpawn, signal, executorRegistry, } = options;
629
+ const { threadName, message, images = [], attachments = [], basePath = CUMULUS_DIR, claudePath, sharedMcpPort, systemPromptTemplate, projectDir, projectRoot, extraMcpServers, gatewayAgentsConfig, onToken, onSegment, onError, onSpawn, signal, executorRegistry, hfApiKey, resumeState, onNeedsInput, } = options;
454
630
  const thread = await getOrCreateThread(threadName, basePath);
455
631
  // ── Build image content blocks ──
456
632
  const imageBlocks = [];
@@ -507,7 +683,7 @@ export async function sendMessage(options) {
507
683
  else {
508
684
  budget.consume(estimateTokens(messageForClaude));
509
685
  }
510
- // ── Persist user message ──
686
+ // ── Persist user message (capture ID for streaming buffer header) ──
511
687
  const userMessage = await thread.history.append({
512
688
  role: 'user',
513
689
  content: message,
@@ -521,6 +697,7 @@ export async function sendMessage(options) {
521
697
  }))
522
698
  : undefined,
523
699
  });
700
+ const userMessageId = userMessage?.id;
524
701
  // ── Stats + recent context ──
525
702
  const stats = await thread.history.getStats();
526
703
  const recentMessages = await thread.history.getRecent(RECENT_CONTEXT_COUNT);
@@ -530,7 +707,7 @@ export async function sendMessage(options) {
530
707
  // ── Config + always-include ──
531
708
  // Resolve executor and cwd
532
709
  const effectiveProjectRoot = projectRoot || path.join(os.homedir(), 'projects');
533
- const tc = await loadThreadConfig(threadName, basePath !== CUMULUS_DIR ? basePath : undefined);
710
+ const tc = await loadThreadConfig(threadName);
534
711
  const executor = tc.executor || 'thundercat';
535
712
  const isRemoteExecutor = executor !== 'thundercat' && executorRegistry?.isOnline(executor);
536
713
  // Resolve cwd: explicit projectDir > executorPaths[executor] > thread config projectDir > default
@@ -573,18 +750,32 @@ export async function sendMessage(options) {
573
750
  console.warn(`[Gateway] scaffold failed for ${threadName}:`, err);
574
751
  }
575
752
  // Persist the resolved path so it survives thread renames
576
- await saveThreadConfig(threadName, { projectDir: threadCwd }, basePath !== CUMULUS_DIR ? basePath : undefined);
753
+ await saveThreadConfig(threadName, { projectDir: threadCwd });
577
754
  }
578
755
  const globalConfig = await loadGlobalConfig();
579
- const threadConfig = await loadThreadConfig(threadName, basePath !== CUMULUS_DIR ? basePath : undefined);
756
+ const threadConfig = await loadThreadConfig(threadName);
580
757
  const mergedConfig = mergeConfigs(globalConfig, threadConfig);
581
758
  const alwaysInclude = await readAlwaysIncludeFiles(mergedConfig, threadCwd);
759
+ // ── Model resolution ──
760
+ // Thread config `model` field determines the execution path:
761
+ // "claude" or absent → existing CLI subprocess (default, zero behavior change)
762
+ // any HuggingFace model ID → agentic loop with HuggingFace provider
763
+ const threadModel = mergedConfig.model || 'claude';
764
+ const isHuggingFace = threadModel !== 'claude' && !!hfApiKey;
765
+ console.log(`[Model] thread=${threadName} model=${threadModel} isHuggingFace=${isHuggingFace} hfApiKey=${!!hfApiKey}`);
766
+ // Clamp adaptive budget to model's actual context window.
767
+ // For HuggingFace models, subtract max_output to reserve space for generation,
768
+ // and subtract a safety margin for tool definitions and other overhead.
769
+ const HF_OVERHEAD_RESERVE = 40_000; // tool defs, deferred list, etc.
770
+ const rawModelLimit = isHuggingFace ? getModelContextLimit(threadModel) : 1_000_000;
771
+ const modelContextLimit = isHuggingFace
772
+ ? rawModelLimit - getModelMaxOutput(threadModel) - HF_OVERHEAD_RESERVE
773
+ : rawModelLimit;
582
774
  // ── RAG budget ──
583
- const adaptiveBudget = thread.adaptive.getTotalContextBudget();
775
+ const rawAdaptiveBudget = thread.adaptive.getTotalContextBudget();
776
+ const adaptiveBudget = Math.min(rawAdaptiveBudget, modelContextLimit);
584
777
  const userQueryTokens = estimateTokens(message);
585
- const overhead = userQueryTokens + alwaysInclude.totalTokens + RECENT_CONTEXT_BUDGET;
586
- const effectiveBudget = resolveRagBudget(mergedConfig, adaptiveBudget, overhead);
587
- const ragBudget = Math.max(0, effectiveBudget - userQueryTokens - alwaysInclude.totalTokens - RECENT_CONTEXT_BUDGET);
778
+ const ragBudget = Math.max(0, adaptiveBudget - userQueryTokens - alwaysInclude.totalTokens - RECENT_CONTEXT_BUDGET);
588
779
  // ── RAG retrieval ──
589
780
  let retrievalResult = null;
590
781
  try {
@@ -666,6 +857,19 @@ export async function sendMessage(options) {
666
857
  const collectedSegments = [];
667
858
  const spawnTime = Date.now();
668
859
  let ttft = null;
860
+ let lastTokenTime = Date.now();
861
+ let activePid = 'n/a'; // set by CLI branch; stays 'n/a' for HF models
862
+ // Stream heartbeat — log every 30s so we can detect silent hangs
863
+ const heartbeat = setInterval(() => {
864
+ const elapsed = Date.now() - spawnTime;
865
+ const sinceLast = Date.now() - lastTokenTime;
866
+ console.error(`[subprocess] HEARTBEAT thread=${threadName} pid=${activePid} elapsed=${elapsed}ms sinceLast=${sinceLast}ms chars=${fullResponse.length}`);
867
+ if (sinceLast > 60_000) {
868
+ console.error(`[subprocess] STALL thread=${threadName} — no output for ${Math.round(sinceLast / 1000)}s. Last 200 chars: ${fullResponse.slice(-200)}`);
869
+ }
870
+ }, 30_000);
871
+ // ── Persistent streaming buffer (survives gateway restart) ──
872
+ let streamBuf = null;
669
873
  const processLine = async (line) => {
670
874
  if (!line.trim())
671
875
  return;
@@ -679,8 +883,13 @@ export async function sendMessage(options) {
679
883
  // Text extraction
680
884
  const text = extractTextFromStreamLine(processed.line);
681
885
  if (text) {
886
+ lastTokenTime = Date.now();
682
887
  if (ttft === null) {
683
888
  ttft = Date.now() - spawnTime;
889
+ console.error(`[subprocess] FIRST_TOKEN thread=${threadName} ttft=${ttft}ms`);
890
+ // Create persistent streaming buffer on first token
891
+ streamBuf = createStreamingBuffer(threadName, userMessageId);
892
+ startStreamingFlush(streamBuf, () => fullResponse);
684
893
  }
685
894
  if (fullResponse && !fullResponse.endsWith('\n')) {
686
895
  fullResponse += '\n\n';
@@ -698,6 +907,11 @@ export async function sendMessage(options) {
698
907
  };
699
908
  // ── Persist response and update session/adaptive (shared between local and remote) ──
700
909
  const persistResponse = async () => {
910
+ // Clean up streaming buffer — response is being persisted to JSONL, tmp no longer needed
911
+ if (streamBuf) {
912
+ cleanupStreamingBuffer(streamBuf);
913
+ streamBuf = null;
914
+ }
701
915
  let assistantMessage = null;
702
916
  if (fullResponse) {
703
917
  assistantMessage = await thread.history.append({
@@ -814,6 +1028,308 @@ export async function sendMessage(options) {
814
1028
  throw err;
815
1029
  }
816
1030
  }
1031
+ // ═══ BRANCH: HuggingFace / agentic loop (non-Claude models) ═══
1032
+ if (isHuggingFace) {
1033
+ const provider = new HuggingFaceProvider(hfApiKey, threadModel);
1034
+ // Build system blocks with cache_control on static parts
1035
+ const systemBlocks = [];
1036
+ // Static instructions (cacheable)
1037
+ const instructions = systemPromptTemplate || SYSTEM_PROMPT_TEMPLATE;
1038
+ const modelIdentity = `You are ${threadModel}. You are NOT Claude, NOT Opus, NOT Sonnet, NOT Haiku. If asked what model you are, say "${threadModel}". `;
1039
+ systemBlocks.push({
1040
+ type: 'text',
1041
+ text: modelIdentity +
1042
+ instructions
1043
+ .replace('{{messageCount}}', String(stats.count))
1044
+ .replace('{{totalTokens}}', String(stats.totalTokens))
1045
+ .replace('{{sessionId}}', thread.session.getSessionId()),
1046
+ cache_control: { type: 'ephemeral' },
1047
+ });
1048
+ // Always-include files
1049
+ if (alwaysInclude.formattedContext) {
1050
+ systemBlocks.push({
1051
+ type: 'text',
1052
+ text: alwaysInclude.formattedContext,
1053
+ cache_control: { type: 'ephemeral' },
1054
+ });
1055
+ }
1056
+ // RAG context (before recent conversation so the model continues naturally)
1057
+ if (retrievedContext) {
1058
+ systemBlocks.push({ type: 'text', text: `\n\n## Retrieved Context\n${retrievedContext}` });
1059
+ }
1060
+ // Recent conversation (last, so the model produces a natural continuation)
1061
+ const recentCtx = formatRecentContext(recentConversation, RECENT_CONTEXT_BUDGET);
1062
+ if (recentCtx) {
1063
+ systemBlocks.push({ type: 'text', text: `\n\n## Recent Conversation\n${recentCtx}` });
1064
+ }
1065
+ // Build core tools + MCP tools as direct function calls
1066
+ const coreTools = getEagerTools();
1067
+ // MCP tools: cumulus-history as direct function calls (namespaced)
1068
+ const mcpToolEntries = [];
1069
+ // Build ToolContext for direct cumulus-history tool dispatch
1070
+ const toolCtx = {
1071
+ historyStore: thread.history,
1072
+ contentStore: thread.content,
1073
+ sessionsPath: path.join(path.dirname(thread.history.threadPath), '..', 'sessions'),
1074
+ currentSessionId: thread.session.getSessionId(),
1075
+ };
1076
+ // Wire all cumulus-history tools as mcp__cumulus_history__* (direct function calls)
1077
+ for (const toolDef of getMcpToolDefinitions()) {
1078
+ const nsName = `mcp__cumulus_history__${toolDef.name}`;
1079
+ mcpToolEntries.push({
1080
+ definition: {
1081
+ name: nsName,
1082
+ description: toolDef.description ?? toolDef.name,
1083
+ input_schema: toolDef.inputSchema,
1084
+ },
1085
+ execute: async (input) => {
1086
+ const result = await handleToolCall(toolDef.name, input, toolCtx);
1087
+ return result.content.map((c) => c.text).join('\n');
1088
+ },
1089
+ });
1090
+ }
1091
+ // Wire gateway-agents tools as mcp__gateway_agents__* (HTTP calls to own server)
1092
+ if (gatewayAgentsConfig) {
1093
+ const { apiUrl, apiKey: agentsApiKey } = gatewayAgentsConfig;
1094
+ const agentsHttpCall = async (method, endpoint, body) => {
1095
+ const http = await import('http');
1096
+ const url = new URL(endpoint, apiUrl);
1097
+ return new Promise((resolve, reject) => {
1098
+ const bodyStr = body ? JSON.stringify(body) : undefined;
1099
+ const req = http.request({
1100
+ hostname: url.hostname,
1101
+ port: url.port,
1102
+ path: url.pathname + url.search,
1103
+ method,
1104
+ headers: {
1105
+ 'Content-Type': 'application/json',
1106
+ 'X-API-Key': agentsApiKey,
1107
+ ...(bodyStr ? { 'Content-Length': String(Buffer.byteLength(bodyStr)) } : {}),
1108
+ },
1109
+ }, res => {
1110
+ let data = '';
1111
+ res.on('data', (chunk) => {
1112
+ data += chunk;
1113
+ });
1114
+ res.on('end', () => {
1115
+ try {
1116
+ resolve(JSON.parse(data));
1117
+ }
1118
+ catch {
1119
+ resolve({ error: `HTTP ${res.statusCode}: ${data}` });
1120
+ }
1121
+ });
1122
+ });
1123
+ req.on('error', (err) => reject(err));
1124
+ if (bodyStr)
1125
+ req.write(bodyStr);
1126
+ req.end();
1127
+ });
1128
+ };
1129
+ mcpToolEntries.push({
1130
+ definition: {
1131
+ name: 'mcp__gateway_agents__list_agents',
1132
+ description: 'List all active threads (agents) on the gateway.',
1133
+ input_schema: { type: 'object', properties: {}, required: [] },
1134
+ },
1135
+ execute: async () => {
1136
+ try {
1137
+ const result = (await agentsHttpCall('GET', '/api/agents'));
1138
+ return JSON.stringify({ agents: result.agents || [], self: threadName });
1139
+ }
1140
+ catch (err) {
1141
+ return `Error: ${err.message}`;
1142
+ }
1143
+ },
1144
+ });
1145
+ mcpToolEntries.push({
1146
+ definition: {
1147
+ name: 'mcp__gateway_agents__send_to_agent',
1148
+ description: 'Send a message to another agent by thread name.',
1149
+ input_schema: {
1150
+ type: 'object',
1151
+ properties: {
1152
+ target: {
1153
+ description: 'Target thread name or array of names',
1154
+ oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
1155
+ },
1156
+ message: { type: 'string', description: 'Message to send' },
1157
+ visibility: {
1158
+ description: 'Visibility: "cc" (default), "blind", or { hidden: [...] }',
1159
+ oneOf: [
1160
+ { type: 'string', enum: ['cc', 'blind'] },
1161
+ {
1162
+ type: 'object',
1163
+ properties: { hidden: { type: 'array', items: { type: 'string' } } },
1164
+ required: ['hidden'],
1165
+ },
1166
+ ],
1167
+ },
1168
+ },
1169
+ required: ['target', 'message'],
1170
+ },
1171
+ },
1172
+ execute: async (input) => {
1173
+ try {
1174
+ const targets = Array.isArray(input.target) ? input.target : [input.target];
1175
+ const body = {
1176
+ targets: targets.filter((t) => t !== threadName),
1177
+ message: input.message,
1178
+ sender: threadName,
1179
+ messageType: 'direct',
1180
+ };
1181
+ if (input.visibility)
1182
+ body.visibility = input.visibility;
1183
+ return JSON.stringify(await agentsHttpCall('POST', '/api/agents/inject', body));
1184
+ }
1185
+ catch (err) {
1186
+ return `Error: ${err.message}`;
1187
+ }
1188
+ },
1189
+ });
1190
+ mcpToolEntries.push({
1191
+ definition: {
1192
+ name: 'mcp__gateway_agents__notify_user',
1193
+ description: "Send a push notification to the user's phone/device.",
1194
+ input_schema: {
1195
+ type: 'object',
1196
+ properties: {
1197
+ message: { type: 'string' },
1198
+ title: { type: 'string' },
1199
+ priority: { type: 'string', enum: ['low', 'normal', 'high'] },
1200
+ },
1201
+ required: ['message'],
1202
+ },
1203
+ },
1204
+ execute: async (input) => {
1205
+ try {
1206
+ return JSON.stringify(await agentsHttpCall('POST', '/api/notify', {
1207
+ title: input.title || threadName,
1208
+ body: input.message,
1209
+ threadName,
1210
+ priority: input.priority || 'normal',
1211
+ }));
1212
+ }
1213
+ catch (err) {
1214
+ return `Error: ${err.message}`;
1215
+ }
1216
+ },
1217
+ });
1218
+ mcpToolEntries.push({
1219
+ definition: {
1220
+ name: 'mcp__gateway_agents__upload_media',
1221
+ description: 'Upload a local file to the media directory. Returns the public URL path.',
1222
+ input_schema: {
1223
+ type: 'object',
1224
+ properties: {
1225
+ source_path: { type: 'string', description: 'Absolute path to the file' },
1226
+ filename: { type: 'string', description: 'Optional desired filename' },
1227
+ },
1228
+ required: ['source_path'],
1229
+ },
1230
+ },
1231
+ execute: async (input) => {
1232
+ try {
1233
+ const fileData = fs.readFileSync(input.source_path);
1234
+ const filename = input.filename || path.basename(input.source_path);
1235
+ return JSON.stringify(await agentsHttpCall('POST', '/api/media/upload', {
1236
+ filename,
1237
+ data: fileData.toString('base64'),
1238
+ encoding: 'base64',
1239
+ }));
1240
+ }
1241
+ catch (err) {
1242
+ return `Error: ${err.message}`;
1243
+ }
1244
+ },
1245
+ });
1246
+ }
1247
+ // Run the agentic loop
1248
+ const spawnTimeApi = Date.now();
1249
+ let ttftApi = null;
1250
+ let firstToken = true;
1251
+ try {
1252
+ const loopResult = await runAgenticLoop({
1253
+ provider,
1254
+ model: threadModel,
1255
+ system: systemBlocks,
1256
+ userMessage: messageForClaude || '',
1257
+ imageBlocks: hasImages ? imageBlocks : undefined,
1258
+ tools: coreTools,
1259
+ mcpTools: mcpToolEntries,
1260
+ eagerAll: true, // HF models can't use ToolSearch — send all tools upfront
1261
+ onToken: text => {
1262
+ if (firstToken) {
1263
+ ttftApi = Date.now() - spawnTimeApi;
1264
+ firstToken = false;
1265
+ // Create persistent streaming buffer on first token
1266
+ streamBuf = createStreamingBuffer(threadName, userMessageId);
1267
+ startStreamingFlush(streamBuf, () => fullResponse);
1268
+ }
1269
+ fullResponse += text;
1270
+ onToken?.(text);
1271
+ },
1272
+ onToolUse: (name, input) => {
1273
+ const segment = { type: 'tool_use', tool: name, input };
1274
+ collectedSegments.push(segment);
1275
+ onSegment?.(segment);
1276
+ },
1277
+ onToolResult: (_name, output, isError) => {
1278
+ const segment = {
1279
+ type: 'tool_result',
1280
+ content: output.slice(0, 500),
1281
+ isError,
1282
+ };
1283
+ collectedSegments.push(segment);
1284
+ onSegment?.(segment);
1285
+ },
1286
+ signal,
1287
+ cwd: threadCwd,
1288
+ threadName,
1289
+ resumeState,
1290
+ });
1291
+ ttft = ttftApi;
1292
+ console.log(`[Gateway] HuggingFace API: ${threadModel}, ${loopResult.turns} turns, ` +
1293
+ `${loopResult.usage.totalInputTokens} in / ${loopResult.usage.totalOutputTokens} out`);
1294
+ if (loopResult.usage.totalOutputTokens === 0 && !loopResult.response.trim()) {
1295
+ console.warn(`[Gateway] WARNING: HuggingFace returned empty response for ${threadName}. ` +
1296
+ `Model context limit: ${rawModelLimit}, effective budget: ${modelContextLimit}, ` +
1297
+ `system prompt tokens: ${debug.systemPromptLength}`);
1298
+ }
1299
+ // Handle AskUserQuestion pause — stop flush interval but keep tmp file
1300
+ if (loopResult.status === 'needs_input' && loopResult.needsInput) {
1301
+ if (streamBuf?.interval) {
1302
+ clearInterval(streamBuf.interval);
1303
+ streamBuf.interval = null;
1304
+ }
1305
+ onNeedsInput?.(loopResult.needsInput);
1306
+ return {
1307
+ response: fullResponse,
1308
+ ttft: ttftApi,
1309
+ userMessage,
1310
+ assistantMessage: null,
1311
+ segments: collectedSegments,
1312
+ debug,
1313
+ needsInput: loopResult.needsInput,
1314
+ };
1315
+ }
1316
+ const assistantMessage = await persistResponse();
1317
+ return {
1318
+ response: fullResponse,
1319
+ ttft,
1320
+ userMessage,
1321
+ assistantMessage,
1322
+ segments: collectedSegments,
1323
+ debug,
1324
+ };
1325
+ }
1326
+ catch (err) {
1327
+ if (fullResponse) {
1328
+ await persistResponse();
1329
+ }
1330
+ throw err;
1331
+ }
1332
+ }
817
1333
  // ── Local execution: spawn Claude on this machine ──
818
1334
  // Build device MCP config if thread has devices configured
819
1335
  let deviceMcpConfig;
@@ -850,6 +1366,10 @@ export async function sendMessage(options) {
850
1366
  env: cleanEnv,
851
1367
  cwd: threadCwd,
852
1368
  });
1369
+ activePid = claude.pid ?? '?';
1370
+ _activeSubprocesses.add(claude);
1371
+ _activeThreadNames.set(claude, threadName);
1372
+ console.error(`[subprocess] SPAWN thread=${threadName} pid=${claude.pid} cwd=${threadCwd} active=${_activeSubprocesses.size}`);
853
1373
  // Expose subprocess ref to caller
854
1374
  onSpawn?.(claude);
855
1375
  // Wire abort signal to kill subprocess
@@ -915,14 +1435,41 @@ export async function sendMessage(options) {
915
1435
  reject(err);
916
1436
  }
917
1437
  };
918
- claude.on('close', async () => {
919
- if (buffer.trim()) {
920
- pendingLines.push(processLine(buffer).catch(() => { }));
1438
+ claude.on('close', (code, sig) => {
1439
+ clearInterval(heartbeat);
1440
+ const duration = Date.now() - spawnTime;
1441
+ const responseLen = fullResponse.length;
1442
+ const toolCalls = collectedSegments.filter(s => s.type === 'tool_use').length;
1443
+ const lastOutput = fullResponse.slice(-200);
1444
+ console.error(`[subprocess] CLOSE thread=${threadName} pid=${claude.pid} code=${code} signal=${sig ?? 'none'} duration=${duration}ms responseChars=${responseLen} toolCalls=${toolCalls} active=${_activeSubprocesses.size}`);
1445
+ if (responseLen > 0) {
1446
+ console.error(`[subprocess] LAST_OUTPUT thread=${threadName}: ...${lastOutput}`);
921
1447
  }
922
- await Promise.all(pendingLines);
923
- handleCompletion();
1448
+ // Complete async work (pending line processing + persistence) BEFORE
1449
+ // removing from tracking set. This ensures drainActiveSubprocesses()
1450
+ // waits for persistence to finish before allowing process.exit().
1451
+ const completion = (async () => {
1452
+ if (buffer.trim()) {
1453
+ pendingLines.push(processLine(buffer).catch(() => { }));
1454
+ }
1455
+ await Promise.all(pendingLines);
1456
+ await handleCompletion();
1457
+ _activeSubprocesses.delete(claude);
1458
+ _activeThreadNames.delete(claude);
1459
+ _completionPromises.delete(claude);
1460
+ })();
1461
+ registerCompletionPromise(claude, completion);
924
1462
  });
925
1463
  claude.on('error', (err) => {
1464
+ clearInterval(heartbeat);
1465
+ _activeSubprocesses.delete(claude);
1466
+ _activeThreadNames.delete(claude);
1467
+ _completionPromises.delete(claude);
1468
+ if (streamBuf) {
1469
+ cleanupStreamingBuffer(streamBuf);
1470
+ streamBuf = null;
1471
+ }
1472
+ console.error(`[subprocess] ERROR thread=${threadName} pid=${claude.pid} error=${err.message} active=${_activeSubprocesses.size}`);
926
1473
  cleanupMcpConfig(mcpConfigPath);
927
1474
  const errorMsg = err.message.includes('ENOENT')
928
1475
  ? 'Claude CLI not found. Please install it first.'