@yemi33/minions 0.1.2377 → 0.1.2378

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.
@@ -130,7 +130,8 @@ function _detectPageChanges(data) {
130
130
  // for the same input. Cross-restart safety lives in the dashboardBuildId
131
131
  // reload path below — RENDER_VERSIONS handles the within-process case.
132
132
  const RENDER_VERSIONS = {
133
- agents: 2,
133
+ // Bumped 2→3 for client-ticked relative last-action timestamps.
134
+ agents: 3,
134
135
  prdProgress: 2,
135
136
  prdPrs: 1,
136
137
  inbox: 2,
@@ -516,17 +517,19 @@ function _processStatusUpdate(data, opts) {
516
517
  // the /api/work-items + /api/pull-requests .then handlers
517
518
  // (_fireCrossSliceRender) where the FRESH fetched lists are available.
518
519
  // Agents now come from /api/agents — a dedicated fresh-JSON endpoint with
519
- // input-mtime ETag (issue #2949). No /api/status outer cache in the path.
520
+ // input-mtime ETag (issue #2949). Conditional requests keep unchanged polls
521
+ // from reparsing the roster and rebuilding every card/timer.
520
522
  // cmdUpdateAgentList consumes the same slice but renders the command-line
521
523
  // chip pop-up so we feed it the fresh array too.
522
524
  _safeRender('agents', function() {
523
525
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
524
526
  window._lastRequestedSeq = window._lastRequestedSeq || {};
525
527
  window._lastRequestedSeq.agents = seq;
526
- fetch('/api/agents')
527
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
528
- .then(function (fresh) {
528
+ _condFetchJson('/api/agents')
529
+ .then(function (resp) {
529
530
  if (seq < (window._lastRequestedSeq.agents || 0)) return;
531
+ if (resp.notModified) return;
532
+ const fresh = resp.data;
530
533
  const list = Array.isArray(fresh) ? fresh : window._lastAgents;
531
534
  if (list) window._lastAgents = list;
532
535
  _safeRender('agents', function() { renderAgents(list); });
@@ -44,6 +44,34 @@ function _modelChipHtml(model) {
44
44
  return '<span class="agent-model-tag" title="Model: ' + escapeHtml(model) + '" style="font-size:var(--text-xs);font-weight:600;letter-spacing:0.4px;padding:1px 5px;margin-left:4px;border:1px solid var(--muted);border-radius:3px;color:var(--muted);background:transparent">' + escapeHtml(model) + '</span>';
45
45
  }
46
46
 
47
+ function _agentHasTimedAction(agent) {
48
+ return !!(agent && agent.lastActionPrefix && Number.isFinite(Number(agent.lastActionAt)));
49
+ }
50
+
51
+ function _formatAgentAction(prefix, actionAt, fallback) {
52
+ var actionAtMs = Number(actionAt);
53
+ var text = prefix && Number.isFinite(actionAtMs)
54
+ ? String(prefix) + ' (' + timeAgo(actionAtMs) + ')'
55
+ : String(fallback || '');
56
+ return text.length > 120 ? text.slice(0, 120) + '...' : text;
57
+ }
58
+
59
+ function _agentLastActionText(agent) {
60
+ return _formatAgentAction(agent.lastActionPrefix, agent.lastActionAt, agent.lastAction);
61
+ }
62
+
63
+ function _agentTimedActionAttrs(agent) {
64
+ return _agentHasTimedAction(agent)
65
+ ? ' data-action-prefix="' + escapeHtml(agent.lastActionPrefix) + '" data-action-at="' + Number(agent.lastActionAt) + '"'
66
+ : '';
67
+ }
68
+
69
+ function _agentActionHtml(agent) {
70
+ var text = _agentLastActionText(agent);
71
+ var attrs = _agentTimedActionAttrs(agent);
72
+ return '<div class="agent-action"' + attrs + ' title="' + escapeHtml(text) + '">' + escapeHtml(text) + '</div>';
73
+ }
74
+
47
75
  function renderAgents(agents) {
48
76
  agentData = agents;
49
77
  const grid = document.getElementById('agents-grid');
@@ -56,7 +84,7 @@ function renderAgents(agents) {
56
84
  <span class="status-badge ${escapeHtml(a.status)}">${escapeHtml(a.status)}</span>
57
85
  </div>
58
86
  <div class="agent-role">${escapeHtml(a.role)}</div>
59
- <div class="agent-action" title="${escapeHtml(a.lastAction)}">${escapeHtml(a.lastAction)}</div>
87
+ ${_agentActionHtml(a)}
60
88
  ${(function() {
61
89
  var s = a.started_at, c = a.completed_at;
62
90
  if (s && c) { var d = new Date(c) - new Date(s); if (d > 0) { var sec = Math.floor(d/1000)%60, min = Math.floor(d/60000)%60, hr = Math.floor(d/3600000); return '<div style="font-size:var(--text-xs);color:var(--muted)">Last run: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's</div>'; } }
@@ -144,7 +172,7 @@ async function openAgentDetail(id) {
144
172
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; status is an internal bounded enum and all user data is wrapped in escapeHtml()/renderMd() (fields: lastAction, blocking tool, resultSummary)
145
173
  document.getElementById('detail-status-line').innerHTML =
146
174
  '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
147
- '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
175
+ '<span class="agent-detail-action"' + _agentTimedActionAttrs(agent) + ' style="color:var(--muted)">' + escapeHtml(_agentLastActionText(agent)) + '</span>' +
148
176
  (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:var(--text-base);color:var(--muted)">&#x23F3; Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
149
177
  (agent.resultSummary ? '<div style="margin-top:4px;font-size:var(--text-base);color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
150
178
 
@@ -168,11 +196,19 @@ async function openAgentDetail(id) {
168
196
 
169
197
  }
170
198
 
171
- // Tick running agent timers on cards every second
199
+ // Tick wall-clock-derived card text every second.
172
200
  var _agentRuntimeTimer = null;
173
201
  function _tickAgentRuntimes() {
174
- var els = document.querySelectorAll('.agent-runtime-tick');
175
- if (els.length === 0) { if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; } return; }
202
+ var grid = document.getElementById('agents-grid');
203
+ var els = grid ? grid.querySelectorAll('.agent-runtime-tick') : [];
204
+ var actionEls = grid ? Array.from(grid.querySelectorAll('.agent-action[data-action-at]')) : [];
205
+ var detailStatus = document.getElementById('detail-status-line');
206
+ var detailAction = detailStatus && detailStatus.querySelector('.agent-detail-action[data-action-at]');
207
+ if (detailAction) actionEls.push(detailAction);
208
+ if (els.length === 0 && actionEls.length === 0) {
209
+ if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; }
210
+ return;
211
+ }
176
212
  var now = Date.now();
177
213
  els.forEach(function(el) {
178
214
  var ms = now - new Date(el.dataset.started).getTime();
@@ -180,13 +216,20 @@ function _tickAgentRuntimes() {
180
216
  var sec = Math.floor(ms / 1000) % 60, min = Math.floor(ms / 60000) % 60, hr = Math.floor(ms / 3600000);
181
217
  el.textContent = 'Running: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
182
218
  });
219
+ actionEls.forEach(function(el) {
220
+ var text = _formatAgentAction(el.dataset.actionPrefix, Number(el.dataset.actionAt), el.textContent);
221
+ el.textContent = text;
222
+ el.title = text;
223
+ });
183
224
  }
184
- // Start ticker after each render if working agents exist
225
+ // Start ticker after each render if any card has wall-clock-derived text.
185
226
  var _origRenderAgents = renderAgents;
186
227
  renderAgents = function(agents) {
187
228
  _origRenderAgents(agents);
188
229
  if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; }
189
- if (agents.some(function(a) { return a.status === 'working' && a.started_at; })) {
230
+ if (agents.some(function(a) {
231
+ return (a.status === 'working' && a.started_at) || _agentHasTimedAction(a);
232
+ })) {
190
233
  _tickAgentRuntimes();
191
234
  _agentRuntimeTimer = setInterval(_tickAgentRuntimes, 1000);
192
235
  }
package/dashboard.js CHANGED
@@ -2996,68 +2996,56 @@ function _stateReadContentType(ext) {
2996
2996
  // issue #2949 documented. This pattern bypasses both:
2997
2997
  //
2998
2998
  // 1. Caller supplies an array of input file paths.
2999
- // 2. We statSync each one and take MAX(mtimeMs). That's the ETag.
2999
+ // 2. We asynchronously stat each one and take MAX(mtimeMs). That's the ETag.
3000
3000
  // 3. If the client's If-None-Match matches → 304 with no body.
3001
3001
  // 4. Otherwise call `builder()` to assemble the response from scratch
3002
3002
  // (no outer cache layer to go stale).
3003
3003
  // 5. Stamp ETag + Content-Type and send.
3004
3004
  //
3005
- // Cost per request when ETag matches: N statSync calls (microseconds).
3006
- // Cost on a miss: same N statSync + the builder, which is exactly the
3005
+ // Cost per request when ETag matches: N asynchronous stat calls.
3006
+ // Cost on a miss: the same stat walk + the builder, which is exactly the
3007
3007
  // existing queries.getAgents() / getMetrics() / etc. that /api/status
3008
3008
  // already pays. Net change: same compute, no caching, always-fresh.
3009
- function _maxInputMtimeMs(inputs) {
3010
- let max = 0;
3011
- for (const fp of inputs) {
3012
- try {
3013
- // Use lstatSync so a broken Windows junction (common after DevBox
3014
- // swap) or a circular symlink doesn't block the event loop on a
3015
- // ~30 s OS timeout trying to follow the link. Engine never writes
3016
- // symlinks into state dirs in normal operation, so an lstat that
3017
- // detects a symlink is treated as "skip" — the input is effectively
3018
- // not tracked. (Round-2 review finding #7.)
3019
- const s = fs.lstatSync(fp);
3020
- if (s.isSymbolicLink()) continue;
3021
- if (s.mtimeMs > max) max = s.mtimeMs;
3022
- // Directory inputs: dir mtime only advances on add/remove of
3023
- // immediate entries, not on in-place file edits. Walk two levels
3024
- // down so editing prd/<plan>.json in place (depth 1) and
3025
- // agents/<id>/live-output.log (depth 2) both bust the ETag.
3026
- // Steering edits at agents/<id>/inbox/steering-*.md (depth 3)
3027
- // currently rely on add/remove cadence rather than per-file content
3028
- // mtime — accept this lag rather than walking unbounded.
3029
- // (Review finding #6.)
3030
- if (s.isDirectory()) {
3031
- let level1;
3032
- try { level1 = fs.readdirSync(fp); } catch { level1 = []; }
3033
- for (const name of level1) {
3034
- const child = path.join(fp, name);
3035
- let cs;
3036
- try { cs = fs.lstatSync(child); } catch { continue; }
3037
- if (cs.isSymbolicLink()) continue;
3038
- if (cs.mtimeMs > max) max = cs.mtimeMs;
3039
- if (cs.isDirectory()) {
3040
- let level2;
3041
- try { level2 = fs.readdirSync(child); } catch { level2 = []; }
3042
- for (const inner of level2) {
3043
- try {
3044
- const is = fs.lstatSync(path.join(child, inner));
3045
- if (is.isSymbolicLink()) continue;
3046
- if (is.mtimeMs > max) max = is.mtimeMs;
3047
- } catch { /* skip unreadable */ }
3048
- }
3049
- }
3050
- }
3051
- }
3052
- } catch {
3053
- // Missing input = not yet on disk. Don't bust the ETag — a builder
3054
- // that legitimately depends on the file will surface its own empty
3055
- // state, and the ETag will move once the file appears.
3056
- }
3009
+ async function _freshnessEntryMtimeMs(fp, depth) {
3010
+ let stat;
3011
+ try {
3012
+ // lstat avoids following broken Windows junctions or circular symlinks,
3013
+ // which can incur long OS timeouts. State inputs never require symlinks.
3014
+ stat = await fs.promises.lstat(fp);
3015
+ } catch {
3016
+ return 0;
3057
3017
  }
3058
- return Math.floor(max);
3018
+ if (stat.isSymbolicLink()) return 0;
3019
+
3020
+ let max = stat.mtimeMs;
3021
+ if (!stat.isDirectory() || depth <= 0) return max;
3022
+
3023
+ let names;
3024
+ try {
3025
+ names = await fs.promises.readdir(fp);
3026
+ } catch {
3027
+ return max;
3028
+ }
3029
+ const childMtimes = await Promise.all(
3030
+ names.map(name => _freshnessEntryMtimeMs(path.join(fp, name), depth - 1)),
3031
+ );
3032
+ for (const childMtime of childMtimes) {
3033
+ if (childMtime > max) max = childMtime;
3034
+ }
3035
+ return max;
3036
+ }
3037
+
3038
+ async function _maxInputMtimeMs(inputs) {
3039
+ // Directory mtime only advances on add/remove, not in-place edits. Walk two
3040
+ // levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
3041
+ // Deeper steering edits still rely on add/remove cadence, matching the prior
3042
+ // bounded walk.
3043
+ const mtimes = await Promise.all(
3044
+ inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
3045
+ );
3046
+ return Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0));
3059
3047
  }
3060
- function serveFreshJson(req, res, opts) {
3048
+ async function serveFreshJson(req, res, opts) {
3061
3049
  const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
3062
3050
  const builder = opts && typeof opts.builder === 'function' ? opts.builder : null;
3063
3051
  const tag = opts && opts.tag ? String(opts.tag) : 'v';
@@ -3067,7 +3055,7 @@ function serveFreshJson(req, res, opts) {
3067
3055
  res.end(JSON.stringify({ error: 'serveFreshJson: builder is required' }));
3068
3056
  return;
3069
3057
  }
3070
- const mtime = _maxInputMtimeMs(inputs);
3058
+ const mtime = await _maxInputMtimeMs(inputs);
3071
3059
  const eventVersion = _getCurrentEventVersion();
3072
3060
  const etag = '"' + tag + '-' + mtime + '-' + eventVersion + '"';
3073
3061
  res.setHeader('ETag', etag);
@@ -7677,7 +7665,7 @@ const server = http.createServer(async (req, res) => {
7677
7665
  h = Math.imul(h ^ ((e.size || 0) >>> 0), 16777619);
7678
7666
  h = Math.imul(h ^ Math.floor(e.sortTs || 0), 16777619);
7679
7667
  }
7680
- const sweepMtime = _maxInputMtimeMs([
7668
+ const sweepMtime = await _maxInputMtimeMs([
7681
7669
  path.join(ENGINE_DIR, 'kb-swept.json'),
7682
7670
  path.join(ENGINE_DIR, 'kb-sweep-state.json'),
7683
7671
  ]);
package/engine/queries.js CHANGED
@@ -758,6 +758,8 @@ function getAgents(config) {
758
758
  const s = getAgentStatus(a.id); // derives from dispatch.json
759
759
 
760
760
  let lastAction = 'Waiting for assignment';
761
+ let lastActionPrefix = null;
762
+ let lastActionAt = null;
761
763
  // Show the dispatch task as a stable label for the whole run — don't swap
762
764
  // in the transient per-tool-call `_runningToolDescription`, which reads like
763
765
  // a task change to operators (e.g. an agent's own "Wait until temp dirs
@@ -768,17 +770,23 @@ function getAgents(config) {
768
770
  else if (s.status === 'error') lastAction = `Error: ${s.task}`;
769
771
  else if (steeringInboxFiles.length > 0) {
770
772
  const lastSteer = steeringInboxFiles[steeringInboxFiles.length - 1];
771
- lastAction = `Pending steering: ${lastSteer.file} (${timeSince(lastSteer.createdAtMs)})`;
773
+ lastActionPrefix = `Pending steering: ${lastSteer.file}`;
774
+ lastActionAt = lastSteer.createdAtMs;
775
+ lastAction = `${lastActionPrefix} (${timeSince(lastActionAt)})`;
772
776
  }
773
777
  else if (inboxFiles.length > 0) {
774
778
  const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
775
- try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch { /* optional */ }
779
+ try {
780
+ lastActionPrefix = `Output: ${path.basename(lastOutput)}`;
781
+ lastActionAt = fs.statSync(lastOutput).mtimeMs;
782
+ lastAction = `${lastActionPrefix} (${timeSince(lastActionAt)})`;
783
+ } catch { /* optional */ }
776
784
  }
777
785
 
778
786
  const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
779
787
  if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
780
788
  return {
781
- ...a, runtime, model, status: s.status, lastAction,
789
+ ...a, runtime, model, status: s.status, lastAction, lastActionPrefix, lastActionAt,
782
790
  // Descriptive capability tags; tolerate legacy `skills` configs by
783
791
  // normalizing to `expertise` for the dashboard/settings UI.
784
792
  expertise: a.expertise ?? a.skills ?? [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2377",
3
+ "version": "0.1.2378",
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"