@yemi33/minions 0.1.1579 → 0.1.1580

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/CHANGELOG.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1579 (2026-04-28)
3
+ ## 0.1.1580 (2026-04-28)
4
4
 
5
5
  ### Features
6
+ - stream doc chat progress
6
7
  - hash-dedup, compress+normalize pass, dynamic stale-guard, rich result
7
8
 
8
9
  ### Other
@@ -191,9 +191,36 @@ function _qaBuildAssistantHtml(text, opts) {
191
191
  const pad = opts?.isError ? '' : 'padding-right:24px;';
192
192
  return '<div class="modal-qa-a" style="' + style + '">' +
193
193
  (opts?.isError ? '' : llmCopyBtn()) +
194
- body +
195
- '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;' + pad + '">' + opts.elapsed + 's</div>' +
194
+ body +
195
+ '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;' + pad + '">' + opts.elapsed + 's</div>' +
196
+ '</div>';
197
+ }
198
+
199
+ function _qaBuildLiveProgressHtml(loadingId, label, elapsedSeconds, streamedText, toolsUsed, queueCount) {
200
+ const qaQueueBadge = queueCount > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + queueCount + ' queued</span>' : '';
201
+ let html = '';
202
+ if (toolsUsed && toolsUsed.length > 0) {
203
+ html += '<div style="margin-bottom:6px">';
204
+ toolsUsed.forEach(function(t) {
205
+ const name = typeof t === 'string' ? t : t.name;
206
+ const input = typeof t === 'string' ? {} : (t.input || {});
207
+ html += '<div style="color:var(--muted);font-size:10px;font-family:monospace"><span style="flex-shrink:0">&#9679;</span> ' + formatToolSummary(name, input) + '</div>';
208
+ });
209
+ html += '</div>';
210
+ }
211
+ if (streamedText) html += '<div style="margin-bottom:6px">' + renderMd(streamedText) + '</div>';
212
+ html += '<div style="display:flex;flex-direction:column;align-items:flex-start;gap:6px">' +
213
+ '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">' +
214
+ '<span class="dot-pulse"><span></span><span></span><span></span></span> ' +
215
+ '<span id="' + loadingId + '-text">' + escHtml(label) + '</span>' +
216
+ '</div>' +
217
+ '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">' +
218
+ '<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)">' + elapsedSeconds + 's</span>' +
219
+ '<button onclick="qaAbort()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer">Stop</button>' +
220
+ qaQueueBadge +
221
+ '</div>' +
196
222
  '</div>';
223
+ return html;
197
224
  }
198
225
 
199
226
  function _qaMutateThreadHtml(key, mutate) {
@@ -401,23 +428,49 @@ async function _processQaMessage(message, selection, opts) {
401
428
  const qaPhases = isPlanEdit
402
429
  ? [[0,'Reading plan...'],[3000,'Analyzing structure...'],[8000,'Researching context...'],[15000,'Drafting revisions...'],[30000,'Writing updated plan...'],[60000,'Still working (large document)...'],[120000,'Deep edit in progress...'],[300000,'Almost there...']]
403
430
  : [[0,'Thinking...'],[3000,'Reading document...'],[8000,'Analyzing...'],[20000,'Still working...'],[60000,'Taking a while...']];
404
- const qaTimer = setInterval(() => {
405
- const elapsed = Date.now() - qaStartTime;
406
- const timeEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-time') : null;
407
- const textEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-text') : null;
408
- if (timeEl) timeEl.textContent = Math.floor(elapsed / 1000) + 's';
409
- if (textEl) {
410
- for (let i = qaPhases.length - 1; i >= 0; i--) {
411
- if (elapsed >= qaPhases[i][0]) {
412
- textEl.textContent = qaPhases[i][1];
413
- break;
414
- }
431
+ let streamedText = '';
432
+ let toolsUsed = [];
433
+ function _qaProgressLabel(elapsed) {
434
+ let label = qaPhases[0][1];
435
+ for (let i = qaPhases.length - 1; i >= 0; i--) {
436
+ if (elapsed >= qaPhases[i][0]) {
437
+ label = qaPhases[i][1];
438
+ break;
415
439
  }
416
440
  }
441
+ return label;
442
+ }
443
+ function _qaRenderProgress(persist) {
444
+ const elapsed = Date.now() - qaStartTime;
445
+ const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
446
+ const loadingEl = tmp.querySelector('#' + loadingId);
447
+ if (!loadingEl) return;
448
+ loadingEl.innerHTML = _qaBuildLiveProgressHtml(
449
+ loadingId,
450
+ _qaProgressLabel(elapsed),
451
+ Math.floor(elapsed / 1000),
452
+ streamedText,
453
+ toolsUsed,
454
+ runtime.queue.length
455
+ );
456
+ });
457
+ if (persist) {
458
+ _qaPersistSession(sessionKey, {
459
+ threadHtml: updatedThreadHtml,
460
+ docContext: capturedDocContext,
461
+ filePath: capturedFilePath,
462
+ history: runtime.history,
463
+ queue: runtime.queue,
464
+ });
465
+ }
466
+ }
467
+ const qaTimer = setInterval(() => {
468
+ _qaRenderProgress(false);
417
469
  }, 500);
470
+ _qaRenderProgress(false);
418
471
 
419
472
  try {
420
- const res = await fetch('/api/doc-chat', {
473
+ const res = await fetch('/api/doc-chat/stream', {
421
474
  method: 'POST',
422
475
  headers: { 'Content-Type': 'application/json' },
423
476
  signal: abortController.signal,
@@ -431,76 +484,118 @@ async function _processQaMessage(message, selection, opts) {
431
484
  contentHash: capturedDocContext.content ? capturedDocContext.content.length + ':' + capturedDocContext.content.charCodeAt(0) + ':' + capturedDocContext.content.charCodeAt(capturedDocContext.content.length - 1) : undefined,
432
485
  }),
433
486
  });
434
- const data = await res.json();
435
- clearInterval(qaTimer);
436
- const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
437
487
  let sessionDocContext = { ...capturedDocContext };
488
+ if (!res.ok) {
489
+ const errText = await res.text();
490
+ let errMsg = errText || 'Failed';
491
+ try {
492
+ const parsed = JSON.parse(errText);
493
+ errMsg = parsed.error || errMsg;
494
+ } catch { /* text/plain fallback */ }
495
+ throw new Error(errMsg);
496
+ }
438
497
 
439
- if (data.ok) {
440
- const borderColor = data.edited ? 'var(--green)' : 'var(--blue)';
441
- const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
442
- const answerHtml = _qaBuildAssistantHtml(data.answer + suffix, { borderColor, elapsed: qaElapsed });
443
- const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
444
- const loadingEl = tmp.querySelector('#' + loadingId);
445
- if (loadingEl) loadingEl.remove();
446
- tmp.insertAdjacentHTML('beforeend', answerHtml);
447
- });
448
-
449
- runtime.history.push({ role: 'user', text: message });
450
- runtime.history.push({ role: 'assistant', text: data.answer });
451
- if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
452
-
453
- _qaNotifySidebar(capturedFilePath);
454
- if (data.actions && data.actions.length > 0) {
455
- for (const action of data.actions) await ccExecuteAction(action);
498
+ const reader = res.body.getReader();
499
+ const decoder = new TextDecoder();
500
+ let buf = '';
501
+ let terminalEventSeen = false;
502
+
503
+ async function _qaHandleStreamEvent(evt) {
504
+ if (!evt || !evt.type) return;
505
+ if (evt.type === 'heartbeat') return;
506
+ if (evt.type === 'chunk') {
507
+ streamedText = evt.text || '';
508
+ _qaRenderProgress(true);
509
+ return;
510
+ }
511
+ if (evt.type === 'tool') {
512
+ toolsUsed.push({ name: evt.name, input: evt.input || {} });
513
+ _qaRenderProgress(true);
514
+ return;
456
515
  }
516
+ if (evt.type === 'done') {
517
+ terminalEventSeen = true;
518
+ clearInterval(qaTimer);
519
+ const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
520
+ const borderColor = evt.edited ? 'var(--green)' : 'var(--blue)';
521
+ const suffix = evt.edited ? '\n\n\u2713 Document saved.' : '';
522
+ const answerHtml = _qaBuildAssistantHtml((evt.text || '') + suffix, { borderColor, elapsed: qaElapsed });
523
+ const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
524
+ const loadingEl = tmp.querySelector('#' + loadingId);
525
+ if (loadingEl) loadingEl.remove();
526
+ tmp.insertAdjacentHTML('beforeend', answerHtml);
527
+ });
528
+
529
+ runtime.history.push({ role: 'user', text: message });
530
+ runtime.history.push({ role: 'assistant', text: evt.text || '' });
531
+ if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
532
+
533
+ _qaNotifySidebar(capturedFilePath);
534
+ if (evt.actions && evt.actions.length > 0) {
535
+ for (const action of evt.actions) await ccExecuteAction(action);
536
+ }
457
537
 
458
- if (data.edited && data.content) {
459
- const display = data.content.replace(/^---[\s\S]*?---\n*/m, '');
460
- const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
461
- sessionDocContext.content = display;
462
- sessionDocContext.selection = '';
463
- if (_qaIsActiveSession(sessionKey)) {
464
- const body = document.getElementById('modal-body');
465
- if (isJson) {
466
- body.textContent = display;
467
- } else {
468
- body.innerHTML = renderMd(display);
469
- body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
470
- body.style.whiteSpace = 'normal';
538
+ if (evt.edited && evt.content) {
539
+ const display = evt.content.replace(/^---[\s\S]*?---\n*/m, '');
540
+ const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
541
+ sessionDocContext.content = display;
542
+ sessionDocContext.selection = '';
543
+ if (_qaIsActiveSession(sessionKey)) {
544
+ const body = document.getElementById('modal-body');
545
+ if (isJson) {
546
+ body.textContent = display;
547
+ } else {
548
+ body.innerHTML = renderMd(display);
549
+ body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
550
+ body.style.whiteSpace = 'normal';
551
+ }
552
+ _modalDocContext.content = display;
471
553
  }
472
- _modalDocContext.content = display;
473
554
  }
555
+
556
+ _qaPersistSession(sessionKey, {
557
+ threadHtml: updatedThreadHtml,
558
+ docContext: sessionDocContext,
559
+ filePath: capturedFilePath,
560
+ history: runtime.history,
561
+ queue: runtime.queue,
562
+ });
563
+ return;
474
564
  }
565
+ if (evt.type === 'error') throw new Error(evt.error || 'Failed');
566
+ }
475
567
 
476
- _qaPersistSession(sessionKey, {
477
- threadHtml: updatedThreadHtml,
478
- docContext: sessionDocContext,
479
- filePath: capturedFilePath,
480
- history: runtime.history,
481
- queue: runtime.queue,
482
- });
483
- } else {
484
- const errorHtml = _qaBuildAssistantHtml('Error: ' + (data.error || 'Failed'), { color: 'var(--red)', isError: true, elapsed: qaElapsed });
485
- const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
486
- const loadingEl = tmp.querySelector('#' + loadingId);
487
- if (loadingEl) loadingEl.remove();
488
- tmp.insertAdjacentHTML('beforeend', errorHtml);
489
- });
490
- _qaPersistSession(sessionKey, {
491
- threadHtml: updatedThreadHtml,
492
- docContext: sessionDocContext,
493
- filePath: capturedFilePath,
494
- history: runtime.history,
495
- queue: runtime.queue,
496
- });
568
+ while (true) {
569
+ const readResult = await reader.read();
570
+ if (readResult.done) break;
571
+ buf += decoder.decode(readResult.value, { stream: true });
572
+ const lines = buf.split('\n');
573
+ buf = lines.pop();
574
+ for (let i = 0; i < lines.length; i++) {
575
+ const line = lines[i];
576
+ if (!line.startsWith('data: ')) continue;
577
+ await _qaHandleStreamEvent(JSON.parse(line.slice(6)));
578
+ }
579
+ }
580
+ if (buf.trim()) {
581
+ const trailing = buf.split('\n');
582
+ for (let i = 0; i < trailing.length; i++) {
583
+ const line = trailing[i];
584
+ if (!line.startsWith('data: ')) continue;
585
+ await _qaHandleStreamEvent(JSON.parse(line.slice(6)));
586
+ }
497
587
  }
588
+ if (!terminalEventSeen) throw new Error('The response stream ended before completion.');
498
589
  } catch (e) {
499
590
  clearInterval(qaTimer);
500
591
  const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
501
592
  const messageHtml = e.name === 'AbortError'
502
- ? _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc })
503
- : _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc });
593
+ ? (streamedText
594
+ ? _qaBuildAssistantHtml(streamedText + '\n\n_Stopped._', { borderColor: 'var(--muted)', elapsed: qaElapsedExc })
595
+ : _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc }))
596
+ : (streamedText
597
+ ? _qaBuildAssistantHtml(streamedText + '\n\nError: ' + e.message, { borderColor: 'var(--red)', elapsed: qaElapsedExc })
598
+ : _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc }));
504
599
  const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
505
600
  const loadingEl = tmp.querySelector('#' + loadingId);
506
601
  if (loadingEl) loadingEl.remove();
package/dashboard.js CHANGED
@@ -1291,12 +1291,111 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
1291
1291
  return result;
1292
1292
  }
1293
1293
 
1294
+ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = 900000, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, model, onAbortReady, onChunk, onToolUse } = {}) {
1295
+ if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
1296
+ if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
1297
+ const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
1298
+ const existing = resolveSession(store, sessionKey);
1299
+ let sessionId = existing ? existing.sessionId : null;
1300
+
1301
+ function buildPrompt({ includePreamble = true } = {}) {
1302
+ const parts = (!skipStatePreamble && includePreamble) ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
1303
+ if (extraContext) parts.push(extraContext);
1304
+ parts.push(message);
1305
+ return parts.join('\n\n---\n\n');
1306
+ }
1307
+
1308
+ let result;
1309
+
1310
+ if (sessionId && maxTurns > 1) {
1311
+ const p1 = llm.callLLMStreaming(buildPrompt({ includePreamble: false }), '', {
1312
+ timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort, direct: true,
1313
+ onChunk,
1314
+ onToolUse,
1315
+ });
1316
+ if (onAbortReady) onAbortReady(p1.abort);
1317
+ result = await p1;
1318
+ llm.trackEngineUsage(label, result.usage);
1319
+
1320
+ if (result.text) {
1321
+ updateSession(store, sessionKey, result.sessionId || sessionId, true);
1322
+ return result;
1323
+ }
1324
+
1325
+ const sessionStillValid = llm.isResumeSessionStillValid(result);
1326
+ if (sessionStillValid) {
1327
+ console.log(`[${label}] Resume call failed (code=${result.code}, empty=${!result.text}) but session is still valid — preserving session for retry`);
1328
+ updateSession(store, sessionKey, result.sessionId || sessionId, true);
1329
+ return result;
1330
+ }
1331
+
1332
+ console.log(`[${label}] Resume failed — session appears dead (code=${result.code}, empty=${!result.text}), retrying fresh...`);
1333
+ sessionId = null;
1334
+ if (store === 'cc') {
1335
+ ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
1336
+ safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
1337
+ } else if (sessionKey) {
1338
+ docSessions.delete(sessionKey);
1339
+ schedulePersistDocSessions();
1340
+ }
1341
+ }
1342
+
1343
+ const freshPrompt = buildPrompt();
1344
+ const p2 = llm.callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
1345
+ timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
1346
+ onChunk,
1347
+ onToolUse,
1348
+ });
1349
+ if (onAbortReady) onAbortReady(p2.abort);
1350
+ result = await p2;
1351
+ llm.trackEngineUsage(label, result.usage);
1352
+
1353
+ if (result.text) {
1354
+ updateSession(store, sessionKey, result.sessionId, false);
1355
+ return result;
1356
+ }
1357
+
1358
+ if (maxTurns <= 1) return result;
1359
+ console.log(`[${label}] Fresh call also failed (code=${result.code}, empty=${!result.text}), retrying once more...`);
1360
+ await new Promise(r => setTimeout(r, 2000));
1361
+ const p3 = llm.callLLMStreaming(freshPrompt, CC_STATIC_SYSTEM_PROMPT, {
1362
+ timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
1363
+ onChunk,
1364
+ onToolUse,
1365
+ });
1366
+ if (onAbortReady) onAbortReady(p3.abort);
1367
+ result = await p3;
1368
+ llm.trackEngineUsage(label, result.usage);
1369
+
1370
+ if (result.text) {
1371
+ updateSession(store, sessionKey, result.sessionId, false);
1372
+ }
1373
+ return result;
1374
+ }
1375
+
1294
1376
  // Lightweight content fingerprint — same algorithm used browser-side (no crypto needed)
1295
1377
  function contentFingerprint(str) {
1296
1378
  if (!str) return '';
1297
1379
  return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(str.length - 1);
1298
1380
  }
1299
1381
 
1382
+ function _parseDocChatResultText(text) {
1383
+ const delimIdx = text.indexOf('---DOCUMENT---');
1384
+ if (delimIdx >= 0) {
1385
+ const answerPart = text.slice(0, delimIdx).trim();
1386
+ const { text: answer, actions } = parseCCActions(answerPart);
1387
+ let content = text.slice(delimIdx + '---DOCUMENT---'.length).trim();
1388
+ content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1389
+ return { answer, content, actions };
1390
+ }
1391
+ const { text: stripped, actions } = parseCCActions(text);
1392
+ return { answer: stripped, content: null, actions };
1393
+ }
1394
+
1395
+ function _docChatDisplayText(text) {
1396
+ return _parseDocChatResultText(text).answer;
1397
+ }
1398
+
1300
1399
  // Doc-specific wrapper — adds document context, parses ---DOCUMENT---
1301
1400
  async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
1302
1401
  const sessionKey = filePath || title;
@@ -1349,18 +1448,54 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
1349
1448
  return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
1350
1449
  }
1351
1450
 
1352
- // Parse ---DOCUMENT--- BEFORE actions — document content may contain ===ACTIONS=== literally
1353
- const delimIdx = result.text.indexOf('---DOCUMENT---');
1354
- if (delimIdx >= 0) {
1355
- const answerPart = result.text.slice(0, delimIdx).trim();
1356
- const { text: answer, actions } = parseCCActions(answerPart);
1357
- let content = result.text.slice(delimIdx + '---DOCUMENT---'.length).trim();
1358
- content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
1359
- return { answer, content, actions };
1451
+ return _parseDocChatResultText(result.text);
1452
+ }
1453
+
1454
+ async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady, onChunk, onToolUse }) {
1455
+ const sessionKey = filePath || title;
1456
+ const docSlice = document.slice(0, 20000);
1457
+
1458
+ if (freshSession && sessionKey) {
1459
+ docSessions.delete(sessionKey);
1360
1460
  }
1361
1461
 
1362
- const { text: stripped, actions } = parseCCActions(result.text);
1363
- return { answer: stripped, content: null, actions };
1462
+ const docHash = require('crypto').createHash('md5').update(docSlice).digest('hex').slice(0, 8);
1463
+ const existing = freshSession ? null : resolveSession('doc', sessionKey);
1464
+ const docUnchanged = existing?.sessionId && existing._docHash === docHash;
1465
+
1466
+ let docContext;
1467
+ if (docUnchanged) {
1468
+ docContext = `## Document: ${title || 'Document'}${filePath ? ' (`' + filePath + '`)' : ''}${selection ? '\n**Selected text:**\n> ' + selection.slice(0, 1500) : ''}${canEdit ? '\nIf editing: respond with your explanation, then \`---DOCUMENT---\` on its own line, then the COMPLETE updated file.' : ''}`;
1469
+ } else {
1470
+ docContext = `## Document Context\n**${title || 'Document'}**${filePath ? ' (`' + filePath + '`)' : ''}${isJson ? ' (JSON)' : ''}\n${selection ? '\n**Selected text:**\n> ' + selection.slice(0, 1500) + '\n' : ''}\n\`\`\`\n${docSlice}\n\`\`\`\n${canEdit ? '\nIf editing: respond with your explanation, then \`---DOCUMENT---\` on its own line, then the COMPLETE updated file.' : '\n(Read-only — answer questions only.)'}`;
1471
+ }
1472
+
1473
+ const result = await ccCallStreaming(message, {
1474
+ store: 'doc', sessionKey,
1475
+ extraContext: docContext, label: 'doc-chat',
1476
+ allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
1477
+ maxTurns: canEdit ? 25 : 10,
1478
+ skipStatePreamble: true,
1479
+ ...(model ? { model } : {}),
1480
+ onAbortReady,
1481
+ onChunk: (text) => { if (onChunk) onChunk(_docChatDisplayText(text)); },
1482
+ onToolUse,
1483
+ });
1484
+
1485
+ if (freshSession && sessionKey) {
1486
+ docSessions.delete(sessionKey);
1487
+ schedulePersistDocSessions();
1488
+ } else if (result.code === 0 && result.sessionId) {
1489
+ const session = resolveSession('doc', sessionKey);
1490
+ if (session) session._docHash = docHash;
1491
+ }
1492
+
1493
+ if (result.code !== 0 || !result.text) {
1494
+ console.error(`[doc-chat-stream] Failed: code=${result.code}, empty=${!result.text}, filePath=${filePath}, stderr=${(result.stderr || '').slice(0, 200)}`);
1495
+ return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
1496
+ }
1497
+
1498
+ return _parseDocChatResultText(result.text);
1364
1499
  }
1365
1500
 
1366
1501
  // -- POST helpers --
@@ -3429,7 +3564,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3429
3564
  docChatInFlight.add(docKey);
3430
3565
  // Kill LLM process + release guard if client disconnects (abort/navigation)
3431
3566
  let _docAbort = null;
3432
- req.on('close', () => { docChatInFlight.delete(docKey); if (_docAbort) _docAbort(); });
3567
+ let _docDone = false;
3568
+ req.on('close', () => { if (!_docDone) { docChatInFlight.delete(docKey); if (_docAbort) _docAbort(); } });
3433
3569
 
3434
3570
  try {
3435
3571
  const canEdit = !!body.filePath;
@@ -3478,13 +3614,152 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3478
3614
 
3479
3615
  safeWrite(fullPath, content);
3480
3616
 
3617
+ _docDone = true;
3481
3618
  return jsonReply(res, 200, { ok: true, answer, edited: true, content, actions });
3482
3619
  }
3620
+ _docDone = true;
3483
3621
  return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
3484
- } finally { _docAbort = null; docChatInFlight.delete(docKey); }
3622
+ } finally { _docAbort = null; _docDone = true; docChatInFlight.delete(docKey); }
3485
3623
  } catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
3486
3624
  }
3487
3625
 
3626
+ async function handleDocChatStream(req, res) {
3627
+ let docKey = null;
3628
+ let _docAbort = null;
3629
+ let _docStreamEnded = false;
3630
+ let _docHeartbeatTimer = null;
3631
+ const writeDocEvent = (payload) => {
3632
+ try {
3633
+ res.write('data: ' + JSON.stringify(payload) + '\n\n');
3634
+ return true;
3635
+ } catch {
3636
+ return false;
3637
+ }
3638
+ };
3639
+ const stopDocHeartbeat = () => {
3640
+ if (_docHeartbeatTimer) {
3641
+ clearInterval(_docHeartbeatTimer);
3642
+ _docHeartbeatTimer = null;
3643
+ }
3644
+ };
3645
+ try {
3646
+ const body = await readBody(req);
3647
+ if (!body.message) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'message required' })); return; }
3648
+ if (!body.document) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'document required' })); return; }
3649
+
3650
+ docKey = body.filePath || body.title || 'default';
3651
+ if (docChatInFlight.has(docKey)) {
3652
+ res.statusCode = 429;
3653
+ res.setHeader('Content-Type', 'application/json');
3654
+ res.end(JSON.stringify({ error: 'This document is already being processed — wait for the current response.' }));
3655
+ return;
3656
+ }
3657
+ docChatInFlight.add(docKey);
3658
+
3659
+ const canEdit = !!body.filePath;
3660
+ const isJson = body.filePath?.endsWith('.json');
3661
+ let currentContent = body.document;
3662
+ let fullPath = null;
3663
+ if (canEdit) {
3664
+ try { shared.sanitizePath(body.filePath, MINIONS_DIR); }
3665
+ catch { docChatInFlight.delete(docKey); res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'path must be under minions directory' })); return; }
3666
+ fullPath = path.resolve(MINIONS_DIR, body.filePath);
3667
+ const diskContent = safeRead(fullPath);
3668
+ if (diskContent !== null) {
3669
+ if (!(body.contentHash && contentFingerprint(diskContent) === body.contentHash)) {
3670
+ currentContent = diskContent;
3671
+ }
3672
+ }
3673
+ }
3674
+
3675
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
3676
+ writeDocEvent({ type: 'heartbeat' });
3677
+ _docHeartbeatTimer = setInterval(() => {
3678
+ if (_docStreamEnded) {
3679
+ stopDocHeartbeat();
3680
+ return;
3681
+ }
3682
+ if (!writeDocEvent({ type: 'heartbeat' })) stopDocHeartbeat();
3683
+ }, CC_STREAM_HEARTBEAT_MS);
3684
+
3685
+ req.on('close', () => {
3686
+ if (!_docStreamEnded) {
3687
+ stopDocHeartbeat();
3688
+ docChatInFlight.delete(docKey);
3689
+ if (_docAbort) _docAbort();
3690
+ }
3691
+ });
3692
+
3693
+ try {
3694
+
3695
+ const { answer, content, actions } = await ccDocCallStreaming({
3696
+ message: body.message, document: currentContent, title: body.title,
3697
+ filePath: body.filePath, selection: body.selection, canEdit, isJson,
3698
+ model: body.model || undefined,
3699
+ freshSession: !!body.freshSession,
3700
+ onAbortReady: (abort) => { _docAbort = abort; },
3701
+ onChunk: (text) => { writeDocEvent({ type: 'chunk', text }); },
3702
+ onToolUse: (name, input) => { writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
3703
+ });
3704
+
3705
+ if (!content) {
3706
+ writeDocEvent({ type: 'done', text: answer, edited: false, actions });
3707
+ _docStreamEnded = true;
3708
+ res.end();
3709
+ return;
3710
+ }
3711
+
3712
+ if (isJson) {
3713
+ try { JSON.parse(content); } catch (e) {
3714
+ writeDocEvent({ type: 'done', text: answer + '\n\n(JSON invalid — not saved: ' + e.message + ')', edited: false, actions });
3715
+ _docStreamEnded = true;
3716
+ res.end();
3717
+ return;
3718
+ }
3719
+ }
3720
+
3721
+ if (canEdit && fullPath) {
3722
+ if (body.filePath && /^meetings\//.test(body.filePath) && isJson) {
3723
+ try {
3724
+ const mtg = safeJson(fullPath);
3725
+ if (mtg && (mtg.status === 'completed' || mtg.status === 'archived')) {
3726
+ writeDocEvent({ type: 'done', text: answer, edited: false, actions });
3727
+ _docStreamEnded = true;
3728
+ res.end();
3729
+ return;
3730
+ }
3731
+ } catch { /* proceed with write if can't read */ }
3732
+ }
3733
+
3734
+ safeWrite(fullPath, content);
3735
+ writeDocEvent({ type: 'done', text: answer, edited: true, content, actions });
3736
+ _docStreamEnded = true;
3737
+ res.end();
3738
+ return;
3739
+ }
3740
+
3741
+ writeDocEvent({ type: 'done', text: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
3742
+ _docStreamEnded = true;
3743
+ res.end();
3744
+ } finally {
3745
+ stopDocHeartbeat();
3746
+ docChatInFlight.delete(docKey);
3747
+ }
3748
+ } catch (e) {
3749
+ stopDocHeartbeat();
3750
+ if (docKey) docChatInFlight.delete(docKey);
3751
+ if (!res.headersSent) {
3752
+ res.statusCode = e.statusCode || 500;
3753
+ res.setHeader('Content-Type', 'application/json');
3754
+ try { res.end(JSON.stringify({ error: e.message })); } catch {}
3755
+ } else {
3756
+ writeDocEvent({ type: 'error', error: e.message });
3757
+ _docStreamEnded = true;
3758
+ try { res.end(); } catch {}
3759
+ }
3760
+ }
3761
+ }
3762
+
3488
3763
  async function handleInboxPersist(req, res) {
3489
3764
  try {
3490
3765
  const body = await readBody(req);
@@ -4206,6 +4481,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4206
4481
  }
4207
4482
  if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
4208
4483
  }, CC_STREAM_HEARTBEAT_MS);
4484
+ // Kill LLM process immediately if client disconnects mid-stream.
4209
4485
  // Keep the LLM alive briefly after disconnect so the UI can reattach to the same in-flight turn.
4210
4486
  req.on('close', () => {
4211
4487
  if (!_ccStreamEnded) {
@@ -5229,6 +5505,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5229
5505
 
5230
5506
  // Doc chat
5231
5507
  { method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChat },
5508
+ { method: 'POST', path: '/api/doc-chat/stream', desc: 'Streaming doc chat — SSE with text chunks and tool progress', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChatStream },
5232
5509
 
5233
5510
  // Inbox
5234
5511
  { method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1579",
3
+ "version": "0.1.1580",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"