@yemi33/minions 0.1.253 → 0.1.255

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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.255 (2026-04-03)
4
+
5
+ ### Features
6
+ - streaming CC responses — text appears as it arrives via SSE
7
+
8
+ ### Fixes
9
+ - CC messages no longer show as "queued" after page refresh
10
+
3
11
  ## 0.1.253 (2026-04-03)
4
12
 
5
13
  ### Fixes
@@ -5,6 +5,8 @@ let _ccMessages = JSON.parse(localStorage.getItem('cc-messages') || '[]');
5
5
  let _ccOpen = false;
6
6
  let _ccSending = false;
7
7
  let _ccQueue = [];
8
+ // Clear stale sending state on page load — SSE streams don't survive refresh
9
+ try { localStorage.removeItem('cc-sending'); } catch {}
8
10
 
9
11
  function toggleCommandCenter() {
10
12
  _ccOpen = !_ccOpen;
@@ -40,7 +42,8 @@ function ccRestoreMessages() {
40
42
  // Restore "thinking" indicator if CC was mid-request when page refreshed
41
43
  try {
42
44
  const sendingState = JSON.parse(localStorage.getItem('cc-sending') || 'null');
43
- if (sendingState?.sending && (Date.now() - sendingState.startedAt) < 300000) {
45
+ // Only restore sending state if very recent (< 10s) — page refresh kills the SSE stream
46
+ if (sendingState?.sending && (Date.now() - sendingState.startedAt) < 10000) {
44
47
  _ccSending = true;
45
48
  const elapsed = Date.now() - sendingState.startedAt;
46
49
  const thinking = document.createElement('div');
@@ -171,45 +174,67 @@ async function _ccDoSend(message, skipUserMsg) {
171
174
  }, 500);
172
175
 
173
176
  try {
174
- const ccAbort = AbortSignal.timeout(960000);
175
- const res = await fetch('/api/command-center', {
177
+ // Stream response via SSE — shows text as it arrives
178
+ const res = await fetch('/api/command-center/stream', {
176
179
  method: 'POST', headers: { 'Content-Type': 'application/json' },
177
- body: JSON.stringify({ message, sessionId: _ccSessionId }),
178
- signal: ccAbort
180
+ body: JSON.stringify({ message }),
181
+ signal: AbortSignal.timeout(960000)
179
182
  });
180
- const data = await res.json();
181
183
 
182
- clearInterval(ccTimer);
183
- thinking.remove();
184
-
185
- if (data.error) {
186
- const isBusy = data.error.includes('busy');
187
- ccAddMessage('assistant', '<span style="color:var(--red)">' + escHtml(data.error) + '</span>' +
188
- (isBusy ? ' <button onclick="ccNewSession()" style="margin-top:4px;padding:3px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--blue);cursor:pointer;font-size:10px">Reset CC</button>' : ''));
184
+ if (!res.ok) {
185
+ clearInterval(ccTimer);
186
+ thinking.remove();
187
+ const errText = await res.text();
188
+ ccAddMessage('assistant', '<span style="color:var(--red)">' + escHtml(errText || 'CC error') + '</span>' +
189
+ (errText.includes('busy') ? ' <button onclick="ccNewSession()" style="margin-top:4px;padding:3px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--blue);cursor:pointer;font-size:10px">Reset CC</button>' : ''));
189
190
  return;
190
191
  }
191
192
 
192
- // Track session if server started a new session, clear stale frontend messages
193
- if (data.sessionId) {
194
- if (data.newSession && _ccSessionId && _ccSessionId !== data.sessionId) {
195
- // Session was silently reset (expired/turn limit) — clear old messages except the current one
196
- const el = document.getElementById('cc-messages');
197
- // Keep only the user message we just added (last child)
198
- const lastMsg = el.lastElementChild;
199
- el.innerHTML = '';
200
- if (lastMsg) el.appendChild(lastMsg);
201
- _ccMessages = _ccMessages.slice(-1); // Keep only the message we just sent
193
+ // Create streaming message bubble
194
+ clearInterval(ccTimer);
195
+ thinking.remove();
196
+ const streamDiv = document.createElement('div');
197
+ streamDiv.className = 'cc-msg assistant';
198
+ streamDiv.innerHTML = '<span style="color:var(--muted);font-size:11px">Thinking...</span>';
199
+ document.getElementById('cc-messages').appendChild(streamDiv);
200
+ let streamedText = '';
201
+
202
+ const reader = res.body.getReader();
203
+ const decoder = new TextDecoder();
204
+ let buf = '';
205
+
206
+ while (true) {
207
+ const { done, value } = await reader.read();
208
+ if (done) break;
209
+ buf += decoder.decode(value, { stream: true });
210
+ const lines = buf.split('\n');
211
+ buf = lines.pop();
212
+ for (const line of lines) {
213
+ if (!line.startsWith('data: ')) continue;
214
+ try {
215
+ const evt = JSON.parse(line.slice(6));
216
+ if (evt.type === 'chunk') {
217
+ streamedText = evt.text; // each chunk is the full text so far for this turn
218
+ streamDiv.innerHTML = renderMd(streamedText);
219
+ const msgs = document.getElementById('cc-messages');
220
+ if (msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 150) msgs.scrollTop = msgs.scrollHeight;
221
+ } else if (evt.type === 'done') {
222
+ // Final result — replace with rendered markdown + actions
223
+ const ccElapsed = Math.round((Date.now() - ccStartTime) / 1000);
224
+ streamDiv.innerHTML = renderMd(evt.text || streamedText || '') +
225
+ '<div style="font-size:9px;color:var(--muted);margin-top:6px;display:flex;justify-content:flex-end;padding-right:30px">' + ccElapsed + 's</div>';
226
+ _ccMessages.push({ role: 'assistant', html: streamDiv.innerHTML });
227
+ if (evt.sessionId) { _ccSessionId = evt.sessionId; ccSaveState(); ccUpdateSessionIndicator(); }
228
+ if (evt.actions && evt.actions.length > 0) {
229
+ for (const action of evt.actions) { await ccExecuteAction(action); }
230
+ }
231
+ } else if (evt.type === 'error') {
232
+ streamDiv.innerHTML = '<span style="color:var(--red)">' + escHtml(evt.error) + '</span>';
233
+ }
234
+ } catch { /* incomplete JSON */ }
202
235
  }
203
- _ccSessionId = data.sessionId;
204
- ccSaveState();
205
- ccUpdateSessionIndicator();
206
236
  }
207
237
 
208
- // Render markdown response
209
- const ccElapsed = Math.round((Date.now() - ccStartTime) / 1000);
210
- const rendered = renderMd(data.text || '');
211
- ccAddMessage('assistant', rendered + '<div style="font-size:9px;color:var(--muted);margin-top:6px;display:flex;justify-content:flex-end;padding-right:30px">' + ccElapsed + 's</div>');
212
-
213
238
  // Execute actions
214
239
  if (data.actions && data.actions.length > 0) {
215
240
  for (const action of data.actions) {
package/dashboard.js CHANGED
@@ -2980,6 +2980,55 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2980
2980
  } catch (e) { ccInFlight = false; return jsonReply(res, 500, { error: e.message }); }
2981
2981
  }
2982
2982
 
2983
+ async function handleCommandCenterStream(req, res) {
2984
+ if (checkRateLimit('command-center', 10)) { res.statusCode = 429; res.end('Rate limited'); return; }
2985
+ try {
2986
+ const body = await readBody(req);
2987
+ if (!body.message) { res.statusCode = 400; res.end('message required'); return; }
2988
+ if (ccInFlight && (Date.now() - ccInFlightSince) < CC_INFLIGHT_TIMEOUT_MS) {
2989
+ res.statusCode = 429; res.end('CC busy'); return;
2990
+ }
2991
+ if (ccInFlight) console.log('[CC-stream] Auto-releasing stuck guard');
2992
+ ccInFlight = true;
2993
+ ccInFlightSince = Date.now();
2994
+
2995
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
2996
+
2997
+ const sessionId = ccSessionValid() ? ccSession.sessionId : null;
2998
+ const preamble = buildCCStatePreamble();
2999
+ const prompt = preamble + '\n\n---\n\n' + body.message;
3000
+
3001
+ const { callLLMStreaming, trackEngineUsage: trackUsage } = require('./engine/llm');
3002
+ const result = await callLLMStreaming(prompt, CC_STATIC_SYSTEM_PROMPT, {
3003
+ timeout: 900000, label: 'command-center', model: 'sonnet', maxTurns: 50,
3004
+ allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
3005
+ sessionId,
3006
+ onChunk: (text) => {
3007
+ try { res.write('data: ' + JSON.stringify({ type: 'chunk', text }) + '\n\n'); } catch {}
3008
+ }
3009
+ });
3010
+ trackUsage('command-center', result.usage);
3011
+
3012
+ // Update session
3013
+ const now = Date.now();
3014
+ if (result.sessionId) {
3015
+ ccSession = { sessionId: result.sessionId, createdAt: ccSession.createdAt || now, lastActiveAt: now, turnCount: (ccSession.turnCount || 0) + 1 };
3016
+ safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
3017
+ }
3018
+
3019
+ // Send final result with actions
3020
+ const { text: displayText, actions } = parseCCActions(result.text);
3021
+ res.write('data: ' + JSON.stringify({ type: 'done', text: displayText, actions, sessionId: ccSession.sessionId }) + '\n\n');
3022
+ res.end();
3023
+ ccInFlight = false;
3024
+ ccInFlightSince = 0;
3025
+ } catch (e) {
3026
+ ccInFlight = false;
3027
+ try { res.write('data: ' + JSON.stringify({ type: 'error', error: e.message }) + '\n\n'); } catch {}
3028
+ try { res.end(); } catch {}
3029
+ }
3030
+ }
3031
+
2983
3032
  async function handleSchedulesList(req, res) {
2984
3033
  reloadConfig();
2985
3034
  const schedules = CONFIG.schedules || [];
@@ -3473,6 +3522,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3473
3522
  // Command Center
3474
3523
  { method: 'POST', path: '/api/command-center/new-session', desc: 'Clear active CC session', handler: handleCommandCenterNewSession },
3475
3524
  { method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, sessionId?', handler: handleCommandCenter },
3525
+ { method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message', handler: handleCommandCenterStream },
3476
3526
 
3477
3527
  // Schedules
3478
3528
  { method: 'POST', path: '/api/schedules/parse-natural', desc: 'Parse natural language schedule text into cron expression', params: 'text', handler: handleSchedulesParseNatural },
package/engine/llm.js CHANGED
@@ -108,8 +108,81 @@ function isResumeSessionStillValid(result) {
108
108
  return false;
109
109
  }
110
110
 
111
+ /**
112
+ * Streaming variant of callLLM — emits text chunks via onChunk callback.
113
+ * Returns the same result object as callLLM when the process completes.
114
+ * onChunk(text) is called for each assistant text block as it arrives.
115
+ */
116
+ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, onChunk = () => {} } = {}) {
117
+ return new Promise((resolve) => {
118
+ const id = uid();
119
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
120
+ if (!require('fs').existsSync(tmpDir)) require('fs').mkdirSync(tmpDir, { recursive: true });
121
+ const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
122
+ const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
123
+ safeWrite(promptPath, promptText);
124
+ safeWrite(sysPath, sysPromptText || '');
125
+
126
+ const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
127
+ const args = [
128
+ spawnScript, promptPath, sysPath,
129
+ '--output-format', 'stream-json', '--max-turns', String(maxTurns), '--model', model,
130
+ '--verbose',
131
+ ];
132
+ if (allowedTools) args.push('--allowedTools', allowedTools);
133
+ args.push('--permission-mode', 'bypassPermissions');
134
+ if (sessionId) args.push('--resume', sessionId);
135
+
136
+ const proc = runFile(process.execPath, args, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
137
+
138
+ let stdout = '';
139
+ let stderr = '';
140
+ let lineBuf = '';
141
+
142
+ proc.stdout.on('data', d => {
143
+ const chunk = d.toString();
144
+ stdout += chunk;
145
+ lineBuf += chunk;
146
+ // Parse complete lines for streaming text
147
+ const lines = lineBuf.split('\n');
148
+ lineBuf = lines.pop(); // keep incomplete line in buffer
149
+ for (const line of lines) {
150
+ const trimmed = line.trim();
151
+ if (!trimmed || !trimmed.startsWith('{')) continue;
152
+ try {
153
+ const obj = JSON.parse(trimmed);
154
+ if (obj.type === 'assistant' && obj.message?.content) {
155
+ for (const block of obj.message.content) {
156
+ if (block.type === 'text' && block.text) onChunk(block.text);
157
+ }
158
+ }
159
+ } catch { /* incomplete JSON or non-JSON line */ }
160
+ }
161
+ });
162
+ proc.stderr.on('data', d => { stderr += d.toString(); });
163
+
164
+ const timer = setTimeout(() => { try { proc.kill('SIGTERM'); } catch {} }, timeout);
165
+
166
+ proc.on('close', (code) => {
167
+ clearTimeout(timer);
168
+ safeUnlink(promptPath);
169
+ safeUnlink(sysPath);
170
+ const parsed = parseStreamJsonOutput(stdout);
171
+ resolve({ text: parsed.text || '', usage: parsed.usage, sessionId: parsed.sessionId || null, code, stderr, raw: stdout });
172
+ });
173
+
174
+ proc.on('error', (err) => {
175
+ clearTimeout(timer);
176
+ safeUnlink(promptPath);
177
+ safeUnlink(sysPath);
178
+ resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '' });
179
+ });
180
+ });
181
+ }
182
+
111
183
  module.exports = {
112
184
  callLLM,
185
+ callLLMStreaming,
113
186
  trackEngineUsage,
114
187
  isResumeSessionStillValid,
115
188
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.253",
3
+ "version": "0.1.255",
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"