@yemi33/minions 0.1.475 → 0.1.477

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.475 (2026-04-07)
3
+ ## 0.1.477 (2026-04-07)
4
4
 
5
5
  ### Features
6
+ - Add mtime-based caching to getPrdInfo()
7
+ - Optimize getAgentStatus() to read only head+tail of live-output.log
6
8
  - Fix unlocked metrics.json in lifecycle.js post-merge hook
7
9
  - Fix unlocked metrics.json read-modify-write in trackEngineUsage
8
10
  - Limit SSE initial live-stream payload to last 64KB
package/engine/queries.js CHANGED
@@ -13,6 +13,35 @@ const { safeRead, safeReadDir, safeJson, safeWrite, getProjects,
13
13
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
14
14
  WI_STATUS } = shared;
15
15
 
16
+ /**
17
+ * Read the first `bytes` and last `bytes` of a file efficiently using byte offsets.
18
+ * For files <= 2*bytes, reads the whole file. Returns { head, tail } strings.
19
+ * Returns { head: '', tail: '' } on any error.
20
+ */
21
+ function readHeadTail(filePath, bytes = 1024) {
22
+ try {
23
+ const stat = fs.statSync(filePath);
24
+ const size = stat.size;
25
+ if (size === 0) return { head: '', tail: '' };
26
+ if (size <= bytes * 2) {
27
+ const full = fs.readFileSync(filePath, 'utf8');
28
+ return { head: full, tail: full };
29
+ }
30
+ const fd = fs.openSync(filePath, 'r');
31
+ try {
32
+ const headBuf = Buffer.alloc(bytes);
33
+ fs.readSync(fd, headBuf, 0, bytes, 0);
34
+ const tailBuf = Buffer.alloc(bytes);
35
+ fs.readSync(fd, tailBuf, 0, bytes, size - bytes);
36
+ return { head: headBuf.toString('utf8'), tail: tailBuf.toString('utf8') };
37
+ } finally {
38
+ fs.closeSync(fd);
39
+ }
40
+ } catch {
41
+ return { head: '', tail: '' };
42
+ }
43
+ }
44
+
16
45
  // ── Paths ───────────────────────────────────────────────────────────────────
17
46
 
18
47
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -130,19 +159,20 @@ function getAgentStatus(agentId) {
130
159
  branch: active.meta?.branch || '',
131
160
  started_at: active.started_at || active.created_at || null,
132
161
  };
133
- // Detect permission-waiting: check live output for non-bypass permission mode
162
+ // Detect permission-waiting: read only head+tail of live-output.log (max 2KB total)
134
163
  try {
135
- const liveLog = shared.safeRead(path.join(AGENTS_DIR, agentId, 'live-output.log'));
136
- if (liveLog) {
137
- // Check init message for permission mode
138
- const initMatch = liveLog.match(/"permissionMode"\s*:\s*"([^"]+)"/);
164
+ const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
165
+ const { head, tail } = readHeadTail(liveLogPath, 1024);
166
+ if (head) {
167
+ // Check init message (in head) for permission mode
168
+ const initMatch = head.match(/"permissionMode"\s*:\s*"([^"]+)"/);
139
169
  if (initMatch && initMatch[1] !== 'bypassPermissions') {
140
170
  result._permissionMode = initMatch[1];
141
171
  }
142
- // Check if agent has been silent for >60s (possible permission prompt wait)
143
- const lastLine = liveLog.trimEnd().split('\n').pop();
172
+ // Check if agent has been silent for >60s (use tail for recent activity)
173
+ const lastLine = tail.trimEnd().split('\n').pop();
144
174
  if (lastLine && lastLine.includes('"type":"assistant"') && lastLine.includes('"tool_use"')) {
145
- const liveStat = fs.statSync(path.join(AGENTS_DIR, agentId, 'live-output.log'));
175
+ const liveStat = fs.statSync(liveLogPath);
146
176
  const silentMs = Date.now() - liveStat.mtimeMs;
147
177
  if (silentMs > 60000 && result._permissionMode) {
148
178
  result._warning = 'Possibly waiting for permission approval — agent is not in bypass mode';
@@ -597,12 +627,51 @@ function getWorkItems(config) {
597
627
 
598
628
  // ── PRD Progress ────────────────────────────────────────────────────────────
599
629
 
630
+ // Module-level caches for getPrdInfo() — avoids re-reading unchanged PRD files
631
+ const _prdFileCache = new Map(); // filePath → { mtimeMs, plan }
632
+ let _prdDirMtimes = { prd: 0, archive: 0 }; // directory mtimes to detect new/deleted files
633
+ let _prdResultCache = null; // cached final result
634
+ let _prdResultInputHash = ''; // hash of all input mtimes to detect any change
635
+
636
+ /**
637
+ * Collect mtimes of all input files that affect getPrdInfo() output.
638
+ * Returns a string hash for quick equality check plus the dir mtimes.
639
+ */
640
+ function _getPrdInputHash(projects) {
641
+ const mtimes = [];
642
+ // PRD directory mtimes (detect new/deleted files)
643
+ let prdDirMtime = 0, archiveDirMtime = 0;
644
+ try { prdDirMtime = fs.statSync(PRD_DIR).mtimeMs; } catch { /* optional */ }
645
+ const archiveDir = path.join(PRD_DIR, 'archive');
646
+ try { archiveDirMtime = fs.statSync(archiveDir).mtimeMs; } catch { /* optional */ }
647
+ mtimes.push(prdDirMtime, archiveDirMtime);
648
+ // Work-items file mtimes (affect status display)
649
+ for (const project of projects) {
650
+ try { mtimes.push(fs.statSync(projectWorkItemsPath(project)).mtimeMs); } catch { mtimes.push(0); }
651
+ }
652
+ try { mtimes.push(fs.statSync(path.join(MINIONS_DIR, 'work-items.json')).mtimeMs); } catch { mtimes.push(0); }
653
+ // PR file mtimes (affect PR links)
654
+ for (const project of projects) {
655
+ try { mtimes.push(fs.statSync(projectPrPath(project)).mtimeMs); } catch { mtimes.push(0); }
656
+ }
657
+ return { hash: mtimes.join(','), prdDirMtime, archiveDirMtime };
658
+ }
659
+
600
660
  function getPrdInfo(config) {
601
661
  config = config || getConfig();
602
662
  const projects = getProjects(config);
663
+
664
+ // Quick mtime check — return cached result if nothing changed
665
+ const { hash, prdDirMtime, archiveDirMtime } = _getPrdInputHash(projects);
666
+ if (_prdResultCache && hash === _prdResultInputHash) return _prdResultCache;
667
+
603
668
  let allPrdItems = [];
604
669
  let latestStat = null;
605
670
 
671
+ // Check if directory listings need refresh
672
+ const dirsChanged = prdDirMtime !== _prdDirMtimes.prd || archiveDirMtime !== _prdDirMtimes.archive;
673
+ _prdDirMtimes = { prd: prdDirMtime, archive: archiveDirMtime };
674
+
606
675
  // Scan active PRDs and archived PRDs (completed PRDs still need to show progress)
607
676
  const planDirs = [
608
677
  { dir: PRD_DIR, archived: false },
@@ -613,10 +682,21 @@ function getPrdInfo(config) {
613
682
  const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
614
683
  for (const pf of planFiles) {
615
684
  try {
616
- const plan = safeJson(path.join(dir, pf));
617
- if (!plan || !plan.missing_features) continue;
618
- const stat = fs.statSync(path.join(dir, pf));
685
+ const filePath = path.join(dir, pf);
686
+ const stat = fs.statSync(filePath);
619
687
  if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
688
+
689
+ // Per-file mtime cache: only re-read files that changed
690
+ const cached = _prdFileCache.get(filePath);
691
+ let plan;
692
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
693
+ plan = cached.plan;
694
+ } else {
695
+ plan = safeJson(filePath);
696
+ _prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
697
+ }
698
+ if (!plan || !plan.missing_features) continue;
699
+
620
700
  // Staleness: compare source plan mtime to recorded sourcePlanModifiedAt
621
701
  let planStale = false;
622
702
  if (!archived && plan.source_plan) {
@@ -638,6 +718,12 @@ function getPrdInfo(config) {
638
718
  }
639
719
  } catch { /* optional */ }
640
720
  }
721
+ // Clean stale entries from file cache when dirs changed
722
+ if (dirsChanged) {
723
+ for (const cachedPath of _prdFileCache.keys()) {
724
+ if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
725
+ }
726
+ }
641
727
  } catch { /* optional */ }
642
728
  }
643
729
 
@@ -736,7 +822,18 @@ function getPrdInfo(config) {
736
822
  missingList: items.filter(i => i.status === 'missing').map(f => ({ id: f.id, name: f.name || f.title, priority: f.priority, complexity: f.estimated_complexity || f.size })),
737
823
  };
738
824
 
739
- return { progress, status };
825
+ const result = { progress, status };
826
+ _prdResultCache = result;
827
+ _prdResultInputHash = hash;
828
+ return result;
829
+ }
830
+
831
+ /** Reset PRD info cache — exported for testing */
832
+ function resetPrdInfoCache() {
833
+ _prdFileCache.clear();
834
+ _prdDirMtimes = { prd: 0, archive: 0 };
835
+ _prdResultCache = null;
836
+ _prdResultInputHash = '';
740
837
  }
741
838
 
742
839
  // ── Exports ─────────────────────────────────────────────────────────────────
@@ -748,6 +845,8 @@ module.exports = {
748
845
 
749
846
  // Helpers
750
847
  timeSince,
848
+ readHeadTail, // exported for testing
849
+ resetPrdInfoCache,
751
850
 
752
851
  // Core state
753
852
  getConfig, getControl, getDispatch, getDispatchQueue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.475",
3
+ "version": "0.1.477",
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"