@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.
- package/dist/gateway/adapters/webchat.d.ts +5 -7
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +204 -57
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/config.d.ts +48 -0
- package/dist/gateway/config.d.ts.map +1 -1
- package/dist/gateway/config.js +90 -0
- package/dist/gateway/config.js.map +1 -1
- package/dist/gateway/daemon.d.ts.map +1 -1
- package/dist/gateway/daemon.js +268 -27
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/gateway-agents-mcp.js +570 -31
- package/dist/gateway/gateway-agents-mcp.js.map +1 -1
- package/dist/gateway/hooks.d.ts +101 -0
- package/dist/gateway/hooks.d.ts.map +1 -0
- package/dist/gateway/hooks.js +200 -0
- package/dist/gateway/hooks.js.map +1 -0
- package/dist/gateway/scheduler.d.ts +50 -0
- package/dist/gateway/scheduler.d.ts.map +1 -0
- package/dist/gateway/scheduler.js +263 -0
- package/dist/gateway/scheduler.js.map +1 -0
- package/dist/gateway/server.d.ts +16 -1
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +451 -22
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/static/blex.min.js +74 -17
- package/dist/gateway/static/chat.html +1 -4
- package/dist/gateway/static/dashboard.html +151 -0
- package/dist/gateway/static/dashboard.js +229 -0
- package/dist/gateway/static/widget.js +656 -460
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/agentic-loop.d.ts +124 -0
- package/dist/lib/agentic-loop.d.ts.map +1 -0
- package/dist/lib/agentic-loop.js +338 -0
- package/dist/lib/agentic-loop.js.map +1 -0
- package/dist/lib/config.d.ts +28 -15
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +47 -41
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/gateway.d.ts +34 -3
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +584 -37
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/huggingface-provider.d.ts +26 -0
- package/dist/lib/huggingface-provider.d.ts.map +1 -0
- package/dist/lib/huggingface-provider.js +338 -0
- package/dist/lib/huggingface-provider.js.map +1 -0
- package/dist/lib/provider.d.ts +71 -0
- package/dist/lib/provider.d.ts.map +1 -0
- package/dist/lib/provider.js +90 -0
- package/dist/lib/provider.js.map +1 -0
- package/dist/lib/tools/ask-user-question.d.ts +14 -0
- package/dist/lib/tools/ask-user-question.d.ts.map +1 -0
- package/dist/lib/tools/ask-user-question.js +74 -0
- package/dist/lib/tools/ask-user-question.js.map +1 -0
- package/dist/lib/tools/bash.d.ts +6 -0
- package/dist/lib/tools/bash.d.ts.map +1 -0
- package/dist/lib/tools/bash.js +85 -0
- package/dist/lib/tools/bash.js.map +1 -0
- package/dist/lib/tools/edit.d.ts +6 -0
- package/dist/lib/tools/edit.d.ts.map +1 -0
- package/dist/lib/tools/edit.js +79 -0
- package/dist/lib/tools/edit.js.map +1 -0
- package/dist/lib/tools/glob.d.ts +6 -0
- package/dist/lib/tools/glob.d.ts.map +1 -0
- package/dist/lib/tools/glob.js +91 -0
- package/dist/lib/tools/glob.js.map +1 -0
- package/dist/lib/tools/grep.d.ts +6 -0
- package/dist/lib/tools/grep.d.ts.map +1 -0
- package/dist/lib/tools/grep.js +110 -0
- package/dist/lib/tools/grep.js.map +1 -0
- package/dist/lib/tools/index.d.ts +29 -0
- package/dist/lib/tools/index.d.ts.map +1 -0
- package/dist/lib/tools/index.js +52 -0
- package/dist/lib/tools/index.js.map +1 -0
- package/dist/lib/tools/read.d.ts +6 -0
- package/dist/lib/tools/read.d.ts.map +1 -0
- package/dist/lib/tools/read.js +80 -0
- package/dist/lib/tools/read.js.map +1 -0
- package/dist/lib/tools/tool-search.d.ts +30 -0
- package/dist/lib/tools/tool-search.d.ts.map +1 -0
- package/dist/lib/tools/tool-search.js +108 -0
- package/dist/lib/tools/tool-search.js.map +1 -0
- package/dist/lib/tools/web-fetch.d.ts +9 -0
- package/dist/lib/tools/web-fetch.d.ts.map +1 -0
- package/dist/lib/tools/web-fetch.js +114 -0
- package/dist/lib/tools/web-fetch.js.map +1 -0
- package/dist/lib/tools/web-search.d.ts +12 -0
- package/dist/lib/tools/web-search.d.ts.map +1 -0
- package/dist/lib/tools/web-search.js +89 -0
- package/dist/lib/tools/web-search.js.map +1 -0
- package/dist/lib/tools/write.d.ts +6 -0
- package/dist/lib/tools/write.d.ts.map +1 -0
- package/dist/lib/tools/write.js +44 -0
- package/dist/lib/tools/write.js.map +1 -0
- package/package.json +3 -2
package/dist/lib/gateway.js
CHANGED
|
@@ -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 {
|
|
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}{
|
|
33
|
-
{
|
|
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,
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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(
|
|
609
|
+
threadCache.set(threadName, thread);
|
|
431
610
|
return thread;
|
|
432
611
|
}
|
|
433
612
|
/** Clear a thread from the cache */
|
|
434
|
-
export function clearThreadCache(threadName
|
|
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
|
|
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 }
|
|
753
|
+
await saveThreadConfig(threadName, { projectDir: threadCwd });
|
|
577
754
|
}
|
|
578
755
|
const globalConfig = await loadGlobalConfig();
|
|
579
|
-
const threadConfig = await loadThreadConfig(threadName
|
|
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
|
|
775
|
+
const rawAdaptiveBudget = thread.adaptive.getTotalContextBudget();
|
|
776
|
+
const adaptiveBudget = Math.min(rawAdaptiveBudget, modelContextLimit);
|
|
584
777
|
const userQueryTokens = estimateTokens(message);
|
|
585
|
-
const
|
|
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',
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
923
|
-
|
|
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.'
|