@yemi33/minions 0.1.2061 → 0.1.2063

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.
@@ -4,11 +4,6 @@ function closeModal() {
4
4
  const modalEl = document.querySelector('#modal .modal');
5
5
  if (modalEl) modalEl.classList.remove('modal-wide');
6
6
  document.getElementById('modal').classList.remove('open');
7
- // Clear the WI-detail auto-refresh tracker so renderWorkItems stops
8
- // rewriting #modal-body each tick. Safe to do unconditionally — if the
9
- // closed modal wasn't a WI detail, these vars were already null.
10
- if (typeof _wiModalOpenId !== 'undefined') { _wiModalOpenId = null; }
11
- if (typeof _wiModalHydratedFields !== 'undefined') { _wiModalHydratedFields = null; }
12
7
  clearModalBackStack();
13
8
  // Hide Q&A section (only shown for document modals)
14
9
  document.getElementById('modal-qa').style.display = 'none';
@@ -69,18 +69,24 @@ function renderAgents(agents) {
69
69
  `).join('');
70
70
  }
71
71
 
72
- // Re-render the detail-panel header (name + emoji + runtime/model chips +
73
- // status badge + lastAction + blocking-tool / resultSummary blocks) from a
74
- // slim agentData record. Extracted so renderAgents can call it every poll
75
- // tick to keep an open detail panel live without re-fetching the expensive
76
- // /api/agent/<id> charter/history/output payload. SEC-03 Phase A rules
77
- // still apply: emoji/name/role go through textContent.
78
- function _renderAgentDetailHeader(agent) {
72
+ async function openAgentDetail(id) {
73
+ const agent = agentData.find(a => a.id === id);
74
+ if (!agent) return;
75
+ currentAgentId = id;
76
+ currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
77
+
78
+ // SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
79
+ // Emoji, name and role are all user-controlled fields; routing them through textContent
80
+ // guarantees no HTML interpretation even if the escape function were ever bypassed.
79
81
  const nameEl = document.getElementById('detail-agent-name');
80
- if (!nameEl) return;
81
82
  const emojiSpan = document.createElement('span');
82
83
  emojiSpan.style.fontSize = '22px';
83
84
  emojiSpan.textContent = agent.emoji || '';
85
+ // Runtime tag \u2014 uses the inline-SVG logo from the same RUNTIME_TAGS map the
86
+ // card uses, so the visual is consistent. The container's user-controlled
87
+ // text fields stay on the textContent path; the SVG is a hardcoded literal
88
+ // from RUNTIME_TAGS keyed by the runtime string (server-controlled, finite
89
+ // set), so injecting via innerHTML on the icon-only span is safe.
84
90
  const runtimeMeta = RUNTIME_TAGS[agent.runtime];
85
91
  const runtimeSpan = document.createElement('span');
86
92
  runtimeSpan.title = 'Runtime: ' + (runtimeMeta?.label || agent.runtime || 'unknown');
@@ -94,6 +100,9 @@ function _renderAgentDetailHeader(agent) {
94
100
  runtimeSpan.style.cssText += ';font-size:10px;font-weight:600;letter-spacing:0.4px;text-transform:uppercase;padding:2px 6px;border:1px solid var(--muted);border-radius:3px;color:var(--muted)';
95
101
  runtimeSpan.textContent = agent.runtime || 'unknown';
96
102
  }
103
+ // W-mpmwxk4y00053271 — mirror the model chip the card shows so the detail
104
+ // header stays in sync. textContent path keeps the model string from being
105
+ // interpreted as HTML.
97
106
  const modelSpan = (agent.model && typeof agent.model === 'string')
98
107
  ? (() => {
99
108
  const s = document.createElement('span');
@@ -112,27 +121,12 @@ function _renderAgentDetailHeader(agent) {
112
121
  nameEl.replaceChildren(...children);
113
122
 
114
123
  const badgeClass = agent.status;
115
- const statusEl = document.getElementById('detail-status-line');
116
- if (statusEl) {
117
- // 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)
118
- statusEl.innerHTML =
119
- '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
120
- '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
121
- (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 (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
122
- (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
123
- }
124
- }
125
-
126
- async function openAgentDetail(id) {
127
- const agent = agentData.find(a => a.id === id);
128
- if (!agent) return;
129
- currentAgentId = id;
130
- currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
131
-
132
- // SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
133
- // Emoji, name and role are all user-controlled fields; routing them through textContent
134
- // guarantees no HTML interpretation even if the escape function were ever bypassed.
135
- _renderAgentDetailHeader(agent);
124
+ // 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)
125
+ document.getElementById('detail-status-line').innerHTML =
126
+ '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
127
+ '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
128
+ (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 (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
129
+ (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
136
130
 
137
131
  // Show panel immediately with loading state — don't wait for API
138
132
  document.getElementById('detail-content').innerHTML = '<div style="padding:24px;text-align:center;color:var(--muted)">Loading...</div>';
@@ -167,12 +161,7 @@ function _tickAgentRuntimes() {
167
161
  el.textContent = 'Running: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
168
162
  });
169
163
  }
170
- // Start ticker after each render if working agents exist, and refresh the
171
- // open agent-detail panel header so status / lastAction / blocking-tool /
172
- // resultSummary track the latest /api/status slice without re-fetching the
173
- // expensive /api/agent/<id> charter/history/output payload. The body tabs
174
- // (thought-process, live, output, etc.) are loaded once on open via
175
- // openAgentDetail's safeFetch — they stay as-is until the user reopens.
164
+ // Start ticker after each render if working agents exist
176
165
  var _origRenderAgents = renderAgents;
177
166
  renderAgents = function(agents) {
178
167
  _origRenderAgents(agents);
@@ -181,10 +170,6 @@ renderAgents = function(agents) {
181
170
  _tickAgentRuntimes();
182
171
  _agentRuntimeTimer = setInterval(_tickAgentRuntimes, 1000);
183
172
  }
184
- if (currentAgentId) {
185
- var open = agents.find(function(a) { return a.id === currentAgentId; });
186
- if (open) _renderAgentDetailHeader(open);
187
- }
188
173
  };
189
174
 
190
175
  window.MinionsAgents = { renderAgents, openAgentDetail };
@@ -4,18 +4,6 @@ let allWorkItems = [];
4
4
  let wiPage = 0;
5
5
  const WI_PER_PAGE = 20;
6
6
 
7
- // Track open WI detail modal so renderWorkItems can re-render its body
8
- // every poll tick. Without this the modal stays frozen on the snapshot
9
- // from the moment it was opened — status flips / agent assignments /
10
- // PR link arrival are invisible until the user closes + reopens or
11
- // hard-refreshes. Mirrors the same fix that drove the section-render
12
- // gate sweep (8ad48509 / W-mpn7keq9000302c9). `_wiModalHydratedFields`
13
- // caches the heavy free-text fields (description, acceptanceCriteria,
14
- // references) loaded once by openWorkItemDetail's GET /api/work-items/<id>
15
- // hydration call so per-tick re-renders don't lose them.
16
- let _wiModalOpenId = null;
17
- let _wiModalHydratedFields = null;
18
-
19
7
  // Track retry state per work item so loading/success/error survives re-renders
20
8
  const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
21
9
  function setWiRetryState(id, state) { _wiRetryState[id] = state; }
@@ -167,27 +155,6 @@ function renderWorkItems(items) {
167
155
  const newWrap = el.querySelector('.pr-table-wrap');
168
156
  if (newWrap) newWrap.scrollLeft = savedScroll;
169
157
  }
170
- // Refresh the open WI detail modal in-place so its status badge,
171
- // agent assignment, PR link, etc. reflect the latest /api/status slice.
172
- // The heavy free-text fields (description, AC, references) live in
173
- // `_wiModalHydratedFields` from the one-time GET /api/work-items/<id>
174
- // hydration and survive across these re-renders.
175
- if (_wiModalOpenId) {
176
- const slim = items.find(i => i.id === _wiModalOpenId);
177
- if (slim) {
178
- const merged = Object.assign({}, _wiModalHydratedFields || {}, slim);
179
- const body = document.getElementById('modal-body');
180
- if (body) {
181
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail()
182
- body.innerHTML = _wiRenderDetail(merged);
183
- }
184
- const title = document.getElementById('modal-title');
185
- if (title) title.textContent = slim.title || slim.id;
186
- }
187
- // If the WI dropped out of the slim slice (deleted/archived), leave
188
- // the modal as-is — the user will close it normally; we don't want
189
- // to auto-dismiss in case they're still reading.
190
- }
191
158
  }
192
159
 
193
160
  async function editWorkItem(id, source) {
@@ -682,15 +649,6 @@ function openWorkItemDetail(id) {
682
649
  (cached.referencesCount > 0 && !Array.isArray(cached.references));
683
650
 
684
651
  const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
685
- // Track which WI's modal is open so renderWorkItems can re-render the
686
- // modal body each poll tick. Reset the hydrated cache; openWorkItemDetail
687
- // is the only authoritative source of the heavy free-text fields.
688
- _wiModalOpenId = id;
689
- _wiModalHydratedFields = needsHydration ? null : {
690
- description: cached.description,
691
- acceptanceCriteria: Array.isArray(cached.acceptanceCriteria) ? cached.acceptanceCriteria : undefined,
692
- references: Array.isArray(cached.references) ? cached.references : undefined,
693
- };
694
652
  document.getElementById('modal-title').textContent = initial.title || initial.id;
695
653
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
696
654
  document.getElementById('modal-body').innerHTML = _wiRenderDetail(initial);
@@ -704,22 +662,18 @@ function openWorkItemDetail(id) {
704
662
  .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
705
663
  .then(function(data) {
706
664
  // Guard against modal navigation away from this WI during the fetch.
707
- if (_wiModalOpenId !== id) return;
665
+ var title = document.getElementById('modal-title');
666
+ if (!title || title.textContent !== (initial.title || initial.id)) return;
708
667
  var full = data && data.item;
709
668
  if (!full) return;
710
- // Cache the heavy free-text fields so per-tick re-renders preserve
711
- // them. We keep them separate from the slim slice (which renderWorkItems
712
- // refreshes on every tick).
713
- _wiModalHydratedFields = {
714
- description: full.description || cached.description || '',
715
- acceptanceCriteria: Array.isArray(full.acceptanceCriteria) ? full.acceptanceCriteria : undefined,
716
- references: Array.isArray(full.references) ? full.references : undefined,
717
- };
718
669
  // Merge: cached cross-slice fields (_pr, _artifacts, etc.) WIN over
719
670
  // the on-disk record so we don't lose engine enrichment that lives
720
671
  // only on the in-memory pass. The full record contributes description,
721
672
  // acceptanceCriteria, and references back to the rendered shape.
722
- var merged = Object.assign({}, full, cached, _wiModalHydratedFields);
673
+ var merged = Object.assign({}, full, cached);
674
+ merged.description = full.description || cached.description || '';
675
+ if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
676
+ if (Array.isArray(full.references)) merged.references = full.references;
723
677
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
724
678
  document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
725
679
  })
package/engine/queries.js CHANGED
@@ -789,12 +789,24 @@ let _skillsCacheKey = null;
789
789
  let _skillIndexCache = null;
790
790
  let _skillIndexCacheTs = 0;
791
791
  let _skillIndexCacheKey = null;
792
+ // Top-level cache for getSkills (the read+parse + meta-build path layered on
793
+ // top of collectSkillFiles). Without this, every /api/status slow-state
794
+ // rebuild re-read and re-parsed all ~80 SKILL.md files (~29ms warm even
795
+ // though collectSkillFiles itself was cached) because getSkills did the
796
+ // I/O on the cached file list. Shares the same 30s TTL + cacheKey so an
797
+ // invalidateSkillsCache() call still flushes everything.
798
+ let _getSkillsResultCache = null;
799
+ let _getSkillsResultCacheTs = 0;
800
+ let _getSkillsResultCacheKey = null;
792
801
  const SKILLS_CACHE_TTL = 30000; // 30s — skill files change rarely (agent extraction, manual authoring)
793
802
 
794
803
  function invalidateSkillsCache() {
795
804
  _skillsCache = null;
796
805
  _skillsCacheTs = 0;
797
806
  _skillsCacheKey = null;
807
+ _getSkillsResultCache = null;
808
+ _getSkillsResultCacheTs = 0;
809
+ _getSkillsResultCacheKey = null;
798
810
  _skillIndexCache = null;
799
811
  _skillIndexCacheTs = 0;
800
812
  _skillIndexCacheKey = null;
@@ -906,6 +918,18 @@ const SKILL_SOURCE_BY_SCOPE = {
906
918
  };
907
919
 
908
920
  function getSkills(config) {
921
+ // Cache the entire read+parse round, not just the file list. /api/status
922
+ // slow-state calls getSkills on every rebuild and re-reading 80+ SKILL.md
923
+ // files + re-parsing their YAML frontmatter cost ~29ms each time (the
924
+ // single biggest hot-path offender after getPrdInfo cold). 30s TTL +
925
+ // shared cacheKey with collectSkillFiles so invalidateSkillsCache() still
926
+ // flushes everything in one shot.
927
+ const now = Date.now();
928
+ config = config || getConfig();
929
+ const cacheKey = _skillsCacheKeyFor(config, os.homedir());
930
+ if (_getSkillsResultCache && _getSkillsResultCacheKey === cacheKey && (now - _getSkillsResultCacheTs) < SKILLS_CACHE_TTL) {
931
+ return _getSkillsResultCache;
932
+ }
909
933
  const all = [];
910
934
  for (const { file: f, dir, scope, projectName, skillName } of collectSkillFiles(config)) {
911
935
  try {
@@ -923,6 +947,9 @@ function getSkills(config) {
923
947
  });
924
948
  } catch { /* optional */ }
925
949
  }
950
+ _getSkillsResultCache = all;
951
+ _getSkillsResultCacheTs = now;
952
+ _getSkillsResultCacheKey = cacheKey;
926
953
  return all;
927
954
  }
928
955
 
@@ -2143,8 +2170,34 @@ function resetProjectGitStatusCache() {
2143
2170
  * so a 5-project fleet runs 14 statSync calls per cache miss — bounded
2144
2171
  * and unmeasurable in benchmarks.
2145
2172
  */
2173
+ // Cache the slow-state path list: it only changes when projects are added/
2174
+ // removed or runtimes are registered/deregistered (essentially process-
2175
+ // lifetime stable in practice). Without it we walked runtime-registry +
2176
+ // projects + resolved per-project gitdir/commondir on every /api/status
2177
+ // rebuild — ~7.5ms per call. The fast-state list is cheaper (~2ms) but
2178
+ // we cache it too for symmetry. Cache key is keyed off the project list
2179
+ // shape so a new project (rare event, goes through configMutation +
2180
+ // invalidateStatusCache) invalidates correctly.
2181
+ let _fastMtimePathsCache = null;
2182
+ let _fastMtimePathsCacheKey = null;
2183
+ let _slowMtimePathsCache = null;
2184
+ let _slowMtimePathsCacheKey = null;
2185
+ function _mtimePathsCacheKey(config) {
2186
+ const projects = getProjects(config).map(p => (p.name || '') + '|' + (p.localPath || ''));
2187
+ return projects.join(';');
2188
+ }
2189
+ function _invalidateMtimePathsCache() {
2190
+ _fastMtimePathsCache = null;
2191
+ _fastMtimePathsCacheKey = null;
2192
+ _slowMtimePathsCache = null;
2193
+ _slowMtimePathsCacheKey = null;
2194
+ }
2195
+
2146
2196
  function getStatusFastStateMtimePaths(config) {
2147
- const projects = getProjects(config || getConfig());
2197
+ config = config || getConfig();
2198
+ const cacheKey = _mtimePathsCacheKey(config);
2199
+ if (_fastMtimePathsCache && _fastMtimePathsCacheKey === cacheKey) return _fastMtimePathsCache;
2200
+ const projects = getProjects(config);
2148
2201
  const files = [
2149
2202
  // Engine-level state surfaced by getDispatchQueue. `control.json`,
2150
2203
  // `log.json`, and `metrics.json` are intentionally omitted — see the
@@ -2234,6 +2287,8 @@ function getStatusFastStateMtimePaths(config) {
2234
2287
  files.push(path.join(commonGitDir, 'FETCH_HEAD'));
2235
2288
  }
2236
2289
  }
2290
+ _fastMtimePathsCache = files;
2291
+ _fastMtimePathsCacheKey = cacheKey;
2237
2292
  return files;
2238
2293
  }
2239
2294
 
@@ -2307,6 +2362,8 @@ function getStatusFastStateMtimePaths(config) {
2307
2362
  */
2308
2363
  function getStatusSlowStateMtimePaths(config) {
2309
2364
  config = config || getConfig();
2365
+ const cacheKey = _mtimePathsCacheKey(config);
2366
+ if (_slowMtimePathsCache && _slowMtimePathsCacheKey === cacheKey) return _slowMtimePathsCache;
2310
2367
  const projects = getProjects(config);
2311
2368
  const homeDir = os.homedir();
2312
2369
  const files = [
@@ -2328,6 +2385,15 @@ function getStatusSlowStateMtimePaths(config) {
2328
2385
  // invalidateStatusCache({includeSlow:true}); tracker entry catches any
2329
2386
  // CLI/editor edit that bypasses the API.
2330
2387
  path.join(MINIONS_DIR, 'pinned.md'),
2388
+ // work-items.json — central + per-project files (per-project pushed below
2389
+ // alongside the PR paths). The PRD progress slice in slow-state is
2390
+ // *derived* from work-item statuses via getPrdInfo's input-hash, so a
2391
+ // WI flipping dispatched→done changes prdProgress without touching any
2392
+ // file in this tracker. Without this entry the slow-state cache hangs
2393
+ // on stale PRD progress for up to 60s after a WI completes (user
2394
+ // report: visit /plans, see all items active; switch to /home + hard
2395
+ // refresh; the WIs are done; return to /plans, now items show as done).
2396
+ path.join(MINIONS_DIR, 'work-items.json'),
2331
2397
  ];
2332
2398
 
2333
2399
  // Skill discovery roots (surfaced by _buildStatusSlowState → getSkills).
@@ -2378,8 +2444,17 @@ function getStatusSlowStateMtimePaths(config) {
2378
2444
  const commonGitDir = _resolveCommonGitDir(gitDir);
2379
2445
  files.push(path.join(gitDir, 'logs', 'HEAD'));
2380
2446
  files.push(path.join(commonGitDir, 'FETCH_HEAD'));
2447
+ // Per-project work-items.json + pull-requests.json — same reason as the
2448
+ // central work-items.json above: the prdProgress slice is derived from
2449
+ // their contents via getPrdInfo's input hash. A WI completion in a
2450
+ // project that uses its own work-items.json file would otherwise hang
2451
+ // slow-state until TTL.
2452
+ try { files.push(projectWorkItemsPath(project)); } catch { /* path helper optional */ }
2453
+ try { files.push(projectPrPath(project)); } catch { /* path helper optional */ }
2381
2454
  }
2382
2455
 
2456
+ _slowMtimePathsCache = files;
2457
+ _slowMtimePathsCacheKey = cacheKey;
2383
2458
  return files;
2384
2459
  }
2385
2460
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2061",
3
+ "version": "0.1.2063",
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"