@yemi33/minions 0.1.25 → 0.1.26

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.26 (2026-03-28)
4
+
5
+ ### Engine
6
+ - engine.js
7
+
8
+ ### Dashboard
9
+ - dashboard.js
10
+ - dashboard/js/detail-panel.js
11
+ - dashboard/js/live-stream.js
12
+
3
13
  ## 0.1.25 (2026-03-28)
4
14
 
5
15
  ### Engine
@@ -63,10 +63,19 @@ function renderDetailContent(detail, tab) {
63
63
 
64
64
  el.innerHTML = html;
65
65
  } else if (tab === 'live') {
66
- el.innerHTML = '<div class="section" id="live-output" style="max-height:60vh;overflow-y:auto;font-size:11px;line-height:1.6">Loading live output...</div>' +
67
- '<div style="margin-top:8px;display:flex;gap:8px;align-items:center">' +
68
- '<span class="pulse"></span><span id="live-status-label" style="font-size:11px;color:var(--green)">Streaming live</span>' +
69
- '<button class="pr-pager-btn" onclick="refreshLiveOutput()" style="font-size:10px">Refresh now</button>' +
66
+ el.innerHTML =
67
+ '<div id="live-chat" style="display:flex;flex-direction:column;height:60vh">' +
68
+ '<div id="live-messages" style="flex:1;overflow-y:auto;padding:8px;font-size:11px;line-height:1.6"></div>' +
69
+ '<div id="live-status-bar" style="padding:4px 8px;display:flex;align-items:center;gap:8px;border-top:1px solid var(--border)">' +
70
+ '<span class="pulse"></span><span id="live-status-label" style="font-size:11px;color:var(--green)">Streaming live</span>' +
71
+ '<button class="pr-pager-btn" onclick="refreshLiveOutput()" style="font-size:10px">Refresh</button>' +
72
+ '</div>' +
73
+ '<div id="live-steer-bar" style="display:flex;gap:8px;padding:8px;border-top:1px solid var(--border)">' +
74
+ '<input id="live-steer-input" type="text" placeholder="Steer this agent... (give additional context or redirect)" ' +
75
+ 'style="flex:1;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:12px;font-family:inherit" ' +
76
+ 'onkeydown="if(event.key===\'Enter\')sendSteering()" />' +
77
+ '<button onclick="sendSteering()" style="padding:6px 12px;background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:11px">Send</button>' +
78
+ '</div>' +
70
79
  '</div>';
71
80
  startLiveStream(currentAgentId);
72
81
  } else if (tab === 'charter') {
@@ -3,29 +3,100 @@
3
3
  let livePollingInterval = null;
4
4
  let liveEventSource = null;
5
5
 
6
+ function renderLiveChatMessage(raw) {
7
+ const el = document.getElementById('live-messages');
8
+ if (!el) return;
9
+
10
+ const lines = raw.split('\n');
11
+ for (const line of lines) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed) continue;
14
+
15
+ // Human steering messages
16
+ if (trimmed.startsWith('[human-steering]')) {
17
+ const msg = trimmed.replace('[human-steering] ', '');
18
+ el.innerHTML += '<div style="align-self:flex-end;background:var(--blue);color:#fff;padding:6px 12px;border-radius:12px 12px 2px 12px;max-width:80%;margin:4px 0;font-size:12px">' + escHtml(msg) + '</div>';
19
+ continue;
20
+ }
21
+
22
+ // Heartbeat lines
23
+ if (trimmed.startsWith('[heartbeat]')) {
24
+ continue;
25
+ }
26
+
27
+ // Try to parse as JSON (stream-json format)
28
+ if (trimmed.startsWith('{')) {
29
+ try {
30
+ const obj = JSON.parse(trimmed);
31
+
32
+ // Assistant text message
33
+ if (obj.type === 'assistant' && obj.message?.content) {
34
+ for (const block of obj.message.content) {
35
+ if (block.type === 'text' && block.text) {
36
+ el.innerHTML += '<div style="background:var(--surface2);padding:8px 12px;border-radius:12px 12px 12px 2px;max-width:90%;margin:4px 0;font-size:12px;white-space:pre-wrap;word-break:break-word">' + escHtml(block.text) + '</div>';
37
+ }
38
+ if (block.type === 'tool_use') {
39
+ el.innerHTML += '<div style="background:var(--surface);border:1px solid var(--border);padding:4px 8px;border-radius:4px;margin:2px 0;font-size:10px;color:var(--muted);cursor:pointer" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\'">' +
40
+ '🔧 ' + escHtml(block.name || 'tool') + '</div>' +
41
+ '<div style="display:none;background:var(--bg);padding:4px 8px;border-radius:4px;margin:0 0 4px;font-size:10px;font-family:monospace;white-space:pre-wrap;max-height:200px;overflow-y:auto;color:var(--muted)">' + escHtml(JSON.stringify(block.input, null, 2).slice(0, 500)) + '</div>';
42
+ }
43
+ }
44
+ }
45
+
46
+ // Tool result
47
+ if (obj.type === 'tool_result' || (obj.type === 'user' && obj.message?.content?.[0]?.type === 'tool_result')) {
48
+ const content = obj.message?.content?.[0]?.content || obj.content || '';
49
+ const text = typeof content === 'string' ? content : JSON.stringify(content);
50
+ if (text.length > 10) {
51
+ el.innerHTML += '<div style="background:var(--bg);border-left:2px solid var(--border);padding:2px 8px;margin:0 0 2px 16px;font-size:9px;font-family:monospace;color:var(--muted);max-height:100px;overflow-y:auto;white-space:pre-wrap;cursor:pointer" onclick="this.style.maxHeight=this.style.maxHeight===\'100px\'?\'none\':\'100px\'">' + escHtml(text.slice(0, 1000)) + (text.length > 1000 ? '...' : '') + '</div>';
52
+ }
53
+ }
54
+
55
+ // Result (final)
56
+ if (obj.type === 'result') {
57
+ el.innerHTML += '<div style="background:rgba(63,185,80,0.1);border:1px solid var(--green);padding:8px 12px;border-radius:8px;margin:8px 0;font-size:12px;color:var(--green)">✓ Task complete</div>';
58
+ }
59
+
60
+ continue;
61
+ } catch {}
62
+ }
63
+
64
+ // Fallback: raw text (stderr, non-JSON lines)
65
+ if (trimmed.startsWith('[stderr]')) {
66
+ el.innerHTML += '<div style="font-size:9px;color:var(--red);font-family:monospace;padding:1px 4px">' + escHtml(trimmed) + '</div>';
67
+ } else {
68
+ el.innerHTML += '<div style="font-size:10px;color:var(--muted);font-family:monospace;padding:1px 4px">' + escHtml(trimmed) + '</div>';
69
+ }
70
+ }
71
+
72
+ // Auto-scroll
73
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 150) {
74
+ el.scrollTop = el.scrollHeight;
75
+ }
76
+ }
77
+
6
78
  function startLiveStream(agentId) {
7
79
  stopLiveStream();
8
80
  if (!agentId) return;
9
81
 
10
- const outputEl = document.getElementById('live-output');
11
- if (outputEl) outputEl.textContent = '';
82
+ const msgEl = document.getElementById('live-messages');
83
+ if (msgEl) msgEl.innerHTML = '';
12
84
 
13
85
  liveEventSource = new EventSource('/api/agent/' + agentId + '/live-stream');
14
86
 
15
87
  liveEventSource.onmessage = function(e) {
16
88
  try {
17
89
  const chunk = JSON.parse(e.data);
18
- const el = document.getElementById('live-output');
19
- if (el) {
20
- const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
21
- el.textContent += chunk;
22
- if (wasAtBottom) el.scrollTop = el.scrollHeight;
23
- }
90
+ renderLiveChatMessage(chunk);
24
91
  } catch {}
25
92
  };
26
93
 
27
94
  liveEventSource.addEventListener('done', function() {
28
95
  stopLiveStream();
96
+ const steerBar = document.getElementById('live-steer-bar');
97
+ if (steerBar) steerBar.style.display = 'none';
98
+ const statusLabel = document.getElementById('live-status-label');
99
+ if (statusLabel) { statusLabel.textContent = 'Completed'; statusLabel.style.color = 'var(--muted)'; }
29
100
  });
30
101
 
31
102
  liveEventSource.onerror = function() {
@@ -59,11 +130,30 @@ async function refreshLiveOutput() {
59
130
  if (!currentAgentId || currentTab !== 'live') { stopLivePolling(); return; }
60
131
  try {
61
132
  const text = await fetch('/api/agent/' + currentAgentId + '/live?tail=16384').then(r => r.text());
62
- const el = document.getElementById('live-output');
133
+ const el = document.getElementById('live-messages');
63
134
  if (el) {
64
- const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
65
- el.textContent = text;
135
+ const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
136
+ el.innerHTML = '';
137
+ renderLiveChatMessage(text);
66
138
  if (wasAtBottom) el.scrollTop = el.scrollHeight;
67
139
  }
68
140
  } catch {}
69
141
  }
142
+
143
+ async function sendSteering() {
144
+ const input = document.getElementById('live-steer-input');
145
+ if (!input || !input.value.trim() || !currentAgentId) return;
146
+ const message = input.value.trim();
147
+ input.value = '';
148
+
149
+ try {
150
+ const res = await fetch('/api/agents/steer', {
151
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ agent: currentAgentId, message })
153
+ });
154
+ if (!res.ok) {
155
+ const d = await res.json().catch(() => ({}));
156
+ alert('Steering failed: ' + (d.error || 'unknown'));
157
+ }
158
+ } catch (e) { alert('Error: ' + e.message); }
159
+ }
package/dashboard.js CHANGED
@@ -2850,6 +2850,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2850
2850
  { method: 'POST', path: '/api/prd/regenerate', desc: 'Regenerate PRD from revised source plan', params: 'file', handler: handlePrdRegenerate },
2851
2851
 
2852
2852
  // Agents
2853
+ { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message', handler: async (req, res) => {
2854
+ const body = await readBody(req);
2855
+ const { agent: agentId, message } = body;
2856
+ if (!agentId || !message) return jsonReply(res, 400, { error: 'agent and message required' });
2857
+
2858
+ const steerPath = path.join(MINIONS_DIR, 'agents', agentId, 'steer.md');
2859
+ const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
2860
+ if (!fs.existsSync(agentDir)) return jsonReply(res, 404, { error: 'Agent not found' });
2861
+
2862
+ // Write steering file — engine picks it up on next tick
2863
+ safeWrite(steerPath, message);
2864
+
2865
+ // Also append to live-output.log so it shows in the chat view
2866
+ const liveLogPath = path.join(agentDir, 'live-output.log');
2867
+ try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' + message + '\n'); } catch {}
2868
+
2869
+ return jsonReply(res, 200, { ok: true, message: 'Steering message sent' });
2870
+ }},
2853
2871
  { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
2854
2872
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/, desc: 'SSE real-time live output streaming', handler: handleAgentLiveStream },
2855
2873
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
package/engine.js CHANGED
@@ -1002,6 +1002,24 @@ function spawnAgent(dispatchItem, config) {
1002
1002
  lastOutputAt = Date.now();
1003
1003
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1004
1004
  try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
1005
+
1006
+ // Capture sessionId early for mid-session steering
1007
+ const procInfo = activeProcesses.get(id);
1008
+ if (procInfo && !procInfo.sessionId && chunk.includes('session_id')) {
1009
+ try {
1010
+ for (const line of chunk.split('\n')) {
1011
+ if (!line.trim() || !line.startsWith('{')) continue;
1012
+ const obj = JSON.parse(line);
1013
+ if (obj.session_id) {
1014
+ procInfo.sessionId = obj.session_id;
1015
+ safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
1016
+ sessionId: obj.session_id, dispatchId: id, savedAt: new Date().toISOString(), branch: branchName
1017
+ });
1018
+ break;
1019
+ }
1020
+ }
1021
+ } catch {}
1022
+ }
1005
1023
  });
1006
1024
 
1007
1025
  proc.stderr.on('data', (data) => {
@@ -1011,9 +1029,72 @@ function spawnAgent(dispatchItem, config) {
1011
1029
  try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
1012
1030
  });
1013
1031
 
1014
- proc.on('close', (code) => {
1032
+ function onAgentClose(code) {
1015
1033
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
1016
1034
  log('info', `Agent ${agentId} (${id}) exited with code ${code}`);
1035
+
1036
+ // Check if this was a steering kill — re-spawn with resume
1037
+ const procInfo = activeProcesses.get(id);
1038
+ if (procInfo?._steeringMessage) {
1039
+ const steerMsg = procInfo._steeringMessage;
1040
+ const steerSessionId = procInfo._steeringSessionId;
1041
+ delete procInfo._steeringMessage;
1042
+ delete procInfo._steeringSessionId;
1043
+
1044
+ log('info', `Steering: re-spawning ${agentId} with --resume ${steerSessionId}`);
1045
+
1046
+ // Write new prompt with steering message
1047
+ const steerPrompt = `HUMAN STEERING MESSAGE:\n\n${steerMsg}\n\nPlease acknowledge this update and adjust your approach accordingly. Continue working on your current task with this new context.`;
1048
+ const steerPromptPath = path.join(ENGINE_DIR, 'tmp', `prompt-steer-${id}.md`);
1049
+ safeWrite(steerPromptPath, steerPrompt);
1050
+
1051
+ // Build resume args
1052
+ const resumeArgs = [
1053
+ '--output-format', claudeConfig?.outputFormat || 'stream-json',
1054
+ '--max-turns', String(engineConfig?.maxTurns || DEFAULTS.maxTurns),
1055
+ '--verbose',
1056
+ '--permission-mode', claudeConfig?.permissionMode || 'bypassPermissions',
1057
+ '--resume', steerSessionId,
1058
+ ];
1059
+ if (claudeConfig?.allowedTools) resumeArgs.push('--allowedTools', claudeConfig.allowedTools);
1060
+
1061
+ const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
1062
+ const childEnv = shared.cleanChildEnv();
1063
+ const resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, steerPromptPath, ...resumeArgs], {
1064
+ cwd,
1065
+ stdio: ['pipe', 'pipe', 'pipe'],
1066
+ env: childEnv,
1067
+ });
1068
+
1069
+ // Re-attach to existing tracking
1070
+ activeProcesses.set(id, { proc: resumeProc, agentId, startedAt: procInfo.startedAt, sessionId: steerSessionId });
1071
+
1072
+ // Re-wire stdout/stderr handlers (same as original)
1073
+ resumeProc.stdout.on('data', (data) => {
1074
+ const chunk = data.toString();
1075
+ lastOutputAt = Date.now();
1076
+ if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1077
+ try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
1078
+ });
1079
+ resumeProc.stderr.on('data', (data) => {
1080
+ const chunk = data.toString();
1081
+ lastOutputAt = Date.now();
1082
+ if (stderr.length < MAX_OUTPUT) stderr += chunk.slice(0, MAX_OUTPUT - stderr.length);
1083
+ try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
1084
+ });
1085
+
1086
+ // Re-wire close handler for the resumed process
1087
+ resumeProc.on('close', onAgentClose);
1088
+ resumeProc.on('error', (err) => {
1089
+ log('error', `Steering re-spawn failed for ${agentId}: ${err.message}`);
1090
+ activeProcesses.delete(id);
1091
+ completeDispatch(id, 'error', `Steering re-spawn error: ${err.message}`);
1092
+ });
1093
+
1094
+ // Don't run completion hooks — agent is still working
1095
+ return;
1096
+ }
1097
+
1017
1098
  activeProcesses.delete(id);
1018
1099
 
1019
1100
  // If timeout checker already finalized this dispatch, don't overwrite work-item status again.
@@ -1069,7 +1150,9 @@ function spawnAgent(dispatchItem, config) {
1069
1150
  log('info', `Temp agent ${agentId} cleaned up`);
1070
1151
  } catch {}
1071
1152
  }
1072
- });
1153
+ }
1154
+
1155
+ proc.on('close', onAgentClose);
1073
1156
 
1074
1157
  proc.on('error', (err) => {
1075
1158
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
@@ -1432,6 +1515,34 @@ function checkIdleThreshold(config) {
1432
1515
  }
1433
1516
  }
1434
1517
 
1518
+ // ─── Steering Checker ────────────────────────────────────────────────────────
1519
+
1520
+ function checkSteering(config) {
1521
+ for (const [id, info] of activeProcesses) {
1522
+ const steerPath = path.join(AGENTS_DIR, info.agentId, 'steer.md');
1523
+ if (!fs.existsSync(steerPath)) continue;
1524
+
1525
+ const message = safeRead(steerPath);
1526
+ try { fs.unlinkSync(steerPath); } catch {}
1527
+ if (!message) continue;
1528
+
1529
+ const sessionId = info.sessionId;
1530
+ if (!sessionId) {
1531
+ log('warn', `Steering: no sessionId for ${info.agentId} — cannot resume. Message dropped.`);
1532
+ continue;
1533
+ }
1534
+
1535
+ log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);
1536
+
1537
+ // Kill current process
1538
+ try { info.proc.kill('SIGTERM'); } catch {}
1539
+
1540
+ // Store steering context for re-spawn on close
1541
+ info._steeringMessage = message;
1542
+ info._steeringSessionId = sessionId;
1543
+ }
1544
+ }
1545
+
1435
1546
  // ─── Timeout Checker ────────────────────────────────────────────────────────
1436
1547
 
1437
1548
  function checkTimeouts(config) {
@@ -3260,8 +3371,9 @@ async function tickInner() {
3260
3371
  const config = getConfig();
3261
3372
  tickCount++;
3262
3373
 
3263
- // 1. Check for timed-out agents and idle threshold
3374
+ // 1. Check for timed-out agents, steering messages, and idle threshold
3264
3375
  checkTimeouts(config);
3376
+ checkSteering(config);
3265
3377
  checkIdleThreshold(config);
3266
3378
 
3267
3379
  // In stopping state, only track agent completions — skip discovery and dispatch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
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"