@yemi33/minions 0.1.668 → 0.1.670

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,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.668 (2026-04-09)
3
+ ## 0.1.670 (2026-04-09)
4
4
 
5
5
  ### Fixes
6
+ - surface blocking tool call state in dashboard (closes #619) (#634)
7
+ - interrupt blocking tool calls immediately on steering (closes #627) (#632)
6
8
  - reduce Bash blocking grace and errored task dedup window (closes #593) (#630)
7
9
 
8
10
  ## 0.1.667 (2026-04-09)
@@ -11,6 +11,7 @@ function renderAgents(agents) {
11
11
  </div>
12
12
  <div class="agent-role">${escHtml(a.role)}</div>
13
13
  <div class="agent-action" title="${escHtml(a.lastAction)}">${escHtml(a.lastAction)}</div>
14
+ ${a._blockingToolCall ? `<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:10px;color:var(--muted)">&#x23F3; Blocking tool call (${escHtml(a._blockingToolCall.tool)}) &mdash; silent ${Math.round(a._blockingToolCall.silentMs/60000)}min, timeout in ${Math.round(a._blockingToolCall.remainingMs/60000)}min</div>` : ''}
14
15
  ${a._warning ? `<div style="margin-top:4px;padding:4px 8px;background:rgba(210,153,34,0.15);border:1px solid rgba(210,153,34,0.3);border-radius:4px;font-size:10px;color:var(--yellow)">&#x26A0; ${escHtml(a._warning)}</div>` : ''}
15
16
  ${a._permissionMode && a._permissionMode !== 'bypassPermissions' && !a._warning ? `<div style="margin-top:4px;font-size:9px;color:var(--muted)">Permission mode: ${escHtml(a._permissionMode)}</div>` : ''}
16
17
  ${a.resultSummary ? `<div class="agent-result" title="${escHtml(a.resultSummary)}">${renderMd(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
@@ -31,6 +32,7 @@ async function openAgentDetail(id) {
31
32
  document.getElementById('detail-status-line').innerHTML =
32
33
  '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
33
34
  '<span style="color:var(--muted)">' + escHtml(agent.lastAction) + '</span>' +
35
+ (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">&#x23F3; Blocking tool call (' + escHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
34
36
  (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
35
37
 
36
38
  // Show panel immediately with loading state — don't wait for API
package/engine/queries.js CHANGED
@@ -216,6 +216,10 @@ function getAgentStatus(agentId) {
216
216
  branch: active.meta?.branch || '',
217
217
  started_at: active.started_at || active.created_at || null,
218
218
  };
219
+ // Surface blocking tool call state from dispatch annotation (set by timeout.js)
220
+ if (active._blockingToolCall) {
221
+ result._blockingToolCall = active._blockingToolCall;
222
+ }
219
223
  // Detect permission-waiting: read only head+tail of live-output.log (max 2KB total)
220
224
  try {
221
225
  const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
@@ -330,6 +334,7 @@ function getAgents(config) {
330
334
  ...a, status: s.status, lastAction,
331
335
  currentTask: (s.task || '').slice(0, 200),
332
336
  resultSummary: (s.resultSummary || '').slice(0, 500),
337
+ _blockingToolCall: s._blockingToolCall || null,
333
338
  _warning: s._warning || null,
334
339
  _permissionMode: s._permissionMode || null,
335
340
  chartered, inboxCount: inboxFiles.length
package/engine/timeout.js CHANGED
@@ -54,9 +54,28 @@ function checkIdleThreshold(config) {
54
54
 
55
55
  // ─── Steering Checker ────────────────────────────────────────────────────────
56
56
 
57
+ // How long to wait for a steered agent to exit before retrying the kill
58
+ const STEERING_KILL_RETRY_MS = 30000;
59
+
57
60
  function checkSteering(config) {
58
61
  const activeProcesses = engine().activeProcesses;
59
62
  for (const [id, info] of activeProcesses) {
63
+ // Recovery: if steering kill hasn't resulted in process exit within 30s, force-retry.
64
+ // This catches cases where killImmediate silently failed (e.g., orphaned subprocess
65
+ // on Unix where SIGKILL only hit spawn-agent.js, not the Claude CLI tree).
66
+ if (info._steeringAt && Date.now() - info._steeringAt > STEERING_KILL_RETRY_MS) {
67
+ if (!info._steeringRetried) {
68
+ log('warn', `Steering: ${info.agentId} (${id}) didn't exit ${STEERING_KILL_RETRY_MS / 1000}s after kill — retrying`);
69
+ shared.killImmediate(info.proc);
70
+ // On Unix, also try to kill children that may have been orphaned
71
+ if (process.platform !== 'win32' && info.proc?.pid) {
72
+ try { shared.exec(`pkill -KILL -P ${info.proc.pid}`, { timeout: 3000 }); } catch { /* children may already be dead */ }
73
+ }
74
+ info._steeringRetried = true;
75
+ }
76
+ continue;
77
+ }
78
+
60
79
  // Skip if already being steered (prevents double-kill race)
61
80
  if (info._steeringMessage || info._steeringAt) continue;
62
81
 
@@ -64,19 +83,34 @@ function checkSteering(config) {
64
83
  let steerMtime;
65
84
  try { steerMtime = fs.statSync(steerPath).mtimeMs; } catch { continue; } // ENOENT = no steering message
66
85
 
86
+ // Read and consume the message immediately — always delete to prevent stale messages
87
+ const message = safeRead(steerPath);
88
+ try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
89
+ if (!message) continue;
90
+
67
91
  const sessionId = info.sessionId;
68
92
  if (!sessionId) {
69
- if (Date.now() - steerMtime > 300000) {
70
- log('warn', `Steering: no sessionId for ${info.agentId} after 5m deleting stale message`);
71
- try { fs.unlinkSync(steerPath); } catch {}
72
- }
93
+ // No session to resume kill agent and deliver message via inbox for retry.
94
+ // Previously this silently skipped for up to 5m then deleted the message (#627).
95
+ log('info', `Steering: no sessionId for ${info.agentId} (${id}) killing and forwarding message to inbox`);
96
+
97
+ // Write steering message to agent inbox so it survives the retry
98
+ const inboxDir = path.join(AGENTS_DIR, info.agentId, 'inbox');
99
+ try { fs.mkdirSync(inboxDir, { recursive: true }); } catch {}
100
+ safeWrite(path.join(inboxDir, `steering-${Date.now()}.md`), `# Steering Message (Forwarded)\n\nOriginal steering from human:\n\n${message}\n`);
101
+
102
+ // Append to live output so user sees confirmation in the dashboard
103
+ try {
104
+ const liveLogPath = path.join(AGENTS_DIR, info.agentId, 'live-output.log');
105
+ fs.appendFileSync(liveLogPath, `\n[steering] Message received but no session to resume. Killing agent — your message will be delivered on retry.\n`);
106
+ } catch { /* optional */ }
107
+
108
+ shared.killImmediate(info.proc);
109
+ info._steeringAt = Date.now();
110
+ info._steeringNoSession = true;
73
111
  continue;
74
112
  }
75
113
 
76
- const message = safeRead(steerPath);
77
- try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
78
- if (!message) continue;
79
-
80
114
  log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);
81
115
 
82
116
  shared.killImmediate(info.proc);
@@ -112,6 +146,7 @@ function checkTimeouts(config) {
112
146
  // Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
113
147
  const dispatchData = getDispatch();
114
148
  const deadItems = [];
149
+ const blockingAnnotations = new Map(); // id → { tool, silentMs, remainingMs } or null (clear)
115
150
 
116
151
  for (const item of (dispatchData.active || [])) {
117
152
  if (!item.agent) continue;
@@ -164,6 +199,7 @@ function checkTimeouts(config) {
164
199
  // Check for BOTH tracked and untracked processes (orphan case after engine restart)
165
200
  let isBlocking = false;
166
201
  let blockingTimeout = heartbeatTimeout;
202
+ let blockingTool = '';
167
203
  if (silentMs > heartbeatTimeout) {
168
204
  try {
169
205
  const liveLog = safeRead(liveLogPath);
@@ -184,6 +220,7 @@ function checkTimeouts(config) {
184
220
  const taskTimeout = input.timeout || 600000; // default 10min
185
221
  blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
186
222
  isBlocking = true;
223
+ blockingTool = 'TaskOutput';
187
224
  }
188
225
  // Bash tool call — may be running a long build/install with no stdout
189
226
  if (name === 'Bash') {
@@ -191,21 +228,35 @@ function checkTimeouts(config) {
191
228
  const bashTimeout = input.timeout || 120000;
192
229
  blockingTimeout = Math.max(heartbeatTimeout, bashTimeout + 60000);
193
230
  isBlocking = true;
231
+ blockingTool = 'Bash';
194
232
  }
195
233
  // Agent (subagent) tool call — parent waits silently for child to complete
196
234
  if (name === 'Agent') {
197
235
  blockingTimeout = Math.max(heartbeatTimeout, 1800000); // 30min for subagents
198
236
  isBlocking = true;
237
+ blockingTool = 'Agent';
199
238
  }
200
239
  break; // only check the most recent tool_use
201
240
  } catch { /* JSON parse — line may not be valid JSON */ }
202
241
  }
203
242
  if (isBlocking) {
204
- log('info', `Agent ${item.agent} (${item.id}) is in a blocking tool call extended timeout to ${Math.round(blockingTimeout / 1000)}s (silent for ${silentSec}s)`);
243
+ // Only log on transitionavoid spamming every tick while blocking persists
244
+ if (!item._blockingToolCall) {
245
+ log('info', `Agent ${item.agent} (${item.id}) is in a blocking tool call (${blockingTool}) — extended timeout to ${Math.round(blockingTimeout / 1000)}s (silent for ${silentSec}s)`, { event: 'blocking_tool_call_detected' });
246
+ }
247
+ blockingAnnotations.set(item.id, {
248
+ tool: blockingTool,
249
+ silentMs,
250
+ remainingMs: Math.max(0, blockingTimeout - silentMs),
251
+ });
205
252
  }
206
253
  }
207
254
  } catch (e) { log('warn', 'blocking tool detection: ' + e.message); }
208
255
  }
256
+ // Agent recovered from blocking state — clear annotation
257
+ if (!isBlocking && item._blockingToolCall) {
258
+ blockingAnnotations.set(item.id, null);
259
+ }
209
260
 
210
261
  const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
211
262
 
@@ -239,6 +290,23 @@ function checkTimeouts(config) {
239
290
  completeDispatch(item.id, DISPATCH_RESULT.ERROR, reason);
240
291
  }
241
292
 
293
+ // Batch-write blocking tool call annotations to dispatch entries.
294
+ // This surfaces blocking state via GET /api/status → dashboard badges.
295
+ if (blockingAnnotations.size > 0) {
296
+ const { mutateDispatch: mutateDispatchFn } = dispatch();
297
+ mutateDispatchFn((dp) => {
298
+ for (const activeItem of dp.active) {
299
+ if (!blockingAnnotations.has(activeItem.id)) continue;
300
+ const ann = blockingAnnotations.get(activeItem.id);
301
+ if (ann) {
302
+ activeItem._blockingToolCall = ann;
303
+ } else {
304
+ delete activeItem._blockingToolCall;
305
+ }
306
+ }
307
+ });
308
+ }
309
+
242
310
  // Agent status is now derived from dispatch.json at read time (getAgentStatus).
243
311
  // No reconcile sweep needed — dispatch IS the source of truth.
244
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.668",
3
+ "version": "0.1.670",
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"