@yemi33/minions 0.1.2002 → 0.1.2004

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.
@@ -0,0 +1,196 @@
1
+ // fre.js — First-Run Experience banner for empty-state dashboards (W-mpehchnt000w46da).
2
+ //
3
+ // Renders a two-step onboarding banner at the top of the main content area when
4
+ // the operator has zero projects configured AND has not explicitly dismissed the
5
+ // banner. Self-contained and idempotent — safe to call renderFre() every tick.
6
+ //
7
+ // Trigger: projects.length === 0 AND localStorage.minions_fre_dismissed !== '1'.
8
+ // Auto-hide (no dismissal flag set) when projects.length >= 1 — the banner has
9
+ // served its purpose. If the user later removes all projects we re-show.
10
+
11
+ const FRE_DISMISS_KEY = 'minions_fre_dismissed';
12
+ const FRE_MOUNT_ID = 'fre-banner';
13
+
14
+ function _freIsDismissed() {
15
+ try { return localStorage.getItem(FRE_DISMISS_KEY) === '1'; } catch { return false; }
16
+ }
17
+
18
+ function dismissFre() {
19
+ try { localStorage.setItem(FRE_DISMISS_KEY, '1'); } catch { /* expected */ }
20
+ const mount = document.getElementById(FRE_MOUNT_ID);
21
+ if (mount) mount.innerHTML = '';
22
+ }
23
+
24
+ // Reset helper (exposed for tests / debugging only). Not wired to UI.
25
+ function _freReset() {
26
+ try { localStorage.removeItem(FRE_DISMISS_KEY); } catch { /* expected */ }
27
+ }
28
+
29
+ // Open the Settings modal and scroll to the Default CLI control (id=set-defaultCli).
30
+ // The modal lives in a separate modal layer, not a sidebar page — openSettings()
31
+ // is async (loads /api/settings) so we await it before scrolling.
32
+ async function openSettingsToDefaultCli() {
33
+ if (typeof openSettings !== 'function') return;
34
+ try {
35
+ await openSettings();
36
+ } catch { /* settings fetch failure already toasts */ return; }
37
+ // openSettings injects innerHTML synchronously after the fetch resolves, so
38
+ // set-defaultCli is in the DOM by now. initRuntimeFleetUI() runs separately
39
+ // and hydrates options — but the <select> element itself is already present.
40
+ setTimeout(function() {
41
+ const el = document.getElementById('set-defaultCli');
42
+ if (!el) return;
43
+ try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* old browser */ }
44
+ try { el.focus({ preventScroll: true }); } catch { /* expected */ }
45
+ const prevOutline = el.style.outline;
46
+ el.style.outline = '2px solid var(--blue)';
47
+ el.style.outlineOffset = '2px';
48
+ setTimeout(function() { el.style.outline = prevOutline; el.style.outlineOffset = ''; }, 2000);
49
+ }, 50);
50
+ }
51
+
52
+ // Render the FRE banner into #fre-banner. Idempotent — bails out early when:
53
+ // • mount point missing (layout didn't load yet)
54
+ // • banner explicitly dismissed
55
+ // • projects.length >= 1 (auto-hide)
56
+ // Otherwise renders the two-step card.
57
+ //
58
+ // Accepts the current /api/status payload (or just a projects array, for tests).
59
+ // When passed the full payload, autoMode.defaultCli drives the runtime label.
60
+ function renderFre(statusOrProjects) {
61
+ const mount = document.getElementById(FRE_MOUNT_ID);
62
+ if (!mount) return;
63
+ const isArr = Array.isArray(statusOrProjects);
64
+ const projects = isArr
65
+ ? statusOrProjects
66
+ : (statusOrProjects && Array.isArray(statusOrProjects.projects) ? statusOrProjects.projects : []);
67
+ const status = isArr ? (window._lastStatus || {}) : (statusOrProjects || {});
68
+ const projectCount = projects.length;
69
+ // Bail-out paths assign an empty string literal on its own line so the
70
+ // SEC-03 dynamic-innerHTML counter (test/unit.test.js
71
+ // DYNAMIC_INNERHTML_BASELINE) treats them as exempt.
72
+ if (projectCount >= 1) {
73
+ mount.innerHTML = '';
74
+ return;
75
+ }
76
+ if (_freIsDismissed()) {
77
+ mount.innerHTML = '';
78
+ return;
79
+ }
80
+
81
+ // Resolve the currently-configured runtime CLI for the explainer copy.
82
+ // /api/status surfaces this as autoMode.defaultCli (resolveAgentCli(null, engine)).
83
+ // Fall back to autoMode.ccCli (also defaultCli-derived when ccCli unset) then 'claude'.
84
+ const auto = (status && status.autoMode) || {};
85
+ const runtimeCli = String(auto.defaultCli || auto.ccCli || 'claude');
86
+
87
+ const cardStyle = [
88
+ 'margin:12px 24px',
89
+ 'padding:16px 20px',
90
+ 'background:var(--surface2)',
91
+ 'border:1px solid var(--blue)',
92
+ 'border-radius:var(--radius-lg)',
93
+ 'color:var(--text)',
94
+ 'font-size:13px',
95
+ 'box-shadow:var(--shadow-md)',
96
+ ].join(';');
97
+
98
+ const stepBox = [
99
+ 'display:flex',
100
+ 'gap:12px',
101
+ 'align-items:flex-start',
102
+ 'padding:10px 12px',
103
+ 'background:var(--surface)',
104
+ 'border:1px solid var(--border)',
105
+ 'border-radius:var(--radius-sm)',
106
+ ].join(';');
107
+
108
+ const stepNum = [
109
+ 'display:inline-flex',
110
+ 'align-items:center',
111
+ 'justify-content:center',
112
+ 'width:22px',
113
+ 'height:22px',
114
+ 'border-radius:var(--radius-full)',
115
+ 'background:var(--blue)',
116
+ 'color:#fff',
117
+ 'font-weight:700',
118
+ 'font-size:11px',
119
+ 'flex-shrink:0',
120
+ ].join(';');
121
+
122
+ const btnPrimary = [
123
+ 'padding:6px 14px',
124
+ 'background:var(--blue)',
125
+ 'color:#fff',
126
+ 'border:none',
127
+ 'border-radius:var(--radius-sm)',
128
+ 'cursor:pointer',
129
+ 'font-size:12px',
130
+ 'font-weight:600',
131
+ 'margin-top:8px',
132
+ ].join(';');
133
+
134
+ const btnGhost = [
135
+ 'padding:6px 14px',
136
+ 'background:transparent',
137
+ 'color:var(--blue)',
138
+ 'border:1px solid var(--blue)',
139
+ 'border-radius:var(--radius-sm)',
140
+ 'cursor:pointer',
141
+ 'font-size:12px',
142
+ 'font-weight:600',
143
+ 'margin-top:8px',
144
+ ].join(';');
145
+
146
+ const dismissBtn = [
147
+ 'background:none',
148
+ 'border:none',
149
+ 'color:var(--muted)',
150
+ 'cursor:pointer',
151
+ 'font-size:11px',
152
+ 'padding:2px 8px',
153
+ ].join(';');
154
+
155
+ // Use textContent-safe interpolation for the runtime label (config-derived, but defensive).
156
+ const safeRuntime = String(runtimeCli).replace(/[<>&"']/g, function(c) {
157
+ return ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' })[c];
158
+ });
159
+
160
+ mount.innerHTML =
161
+ '<div id="fre-card" style="' + cardStyle + '">' +
162
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">' +
163
+ '<div style="font-size:15px;font-weight:700;color:var(--blue)">&#x1F44B; Welcome to Minions</div>' +
164
+ '<button onclick="dismissFre()" style="' + dismissBtn + '" title="Hide this banner — clear localStorage.minions_fre_dismissed to re-show">Dismiss</button>' +
165
+ '</div>' +
166
+ '<div style="color:var(--muted);font-size:12px;margin-bottom:12px">Two quick steps to get your fleet ready.</div>' +
167
+ '<div style="display:flex;flex-direction:column;gap:10px">' +
168
+
169
+ '<div style="' + stepBox + '">' +
170
+ '<span style="' + stepNum + '">1</span>' +
171
+ '<div style="flex:1">' +
172
+ '<div style="font-weight:600;margin-bottom:2px">Pick your runtime CLI</div>' +
173
+ '<div style="color:var(--muted);font-size:12px;line-height:1.5">' +
174
+ 'Minions spawns agents through this CLI. Switch via Settings &rarr; Engine &rarr; Default CLI (<code>claude</code> or <code>copilot</code>). ' +
175
+ 'Currently: <code style="background:var(--bg);padding:1px 6px;border-radius:3px;color:var(--text)">' + safeRuntime + '</code>' +
176
+ '</div>' +
177
+ '<button onclick="openSettingsToDefaultCli()" style="' + btnGhost + '">Open Settings &rarr; Default CLI</button>' +
178
+ '</div>' +
179
+ '</div>' +
180
+
181
+ '<div style="' + stepBox + '">' +
182
+ '<span style="' + stepNum + '">2</span>' +
183
+ '<div style="flex:1">' +
184
+ '<div style="font-weight:600;margin-bottom:2px">Add your first project</div>' +
185
+ '<div style="color:var(--muted);font-size:12px;line-height:1.5">' +
186
+ 'Projects map a local git worktree to a remote repo. Without a project, agents have nowhere to run.' +
187
+ '</div>' +
188
+ '<button onclick="addProject()" style="' + btnPrimary + '">+ Add Project</button>' +
189
+ '</div>' +
190
+ '</div>' +
191
+
192
+ '</div>' +
193
+ '</div>';
194
+ }
195
+
196
+ window.MinionsFre = { renderFre, dismissFre, openSettingsToDefaultCli, _freReset, FRE_DISMISS_KEY };
@@ -79,6 +79,12 @@ function _processStatusUpdate(data) {
79
79
  if (_changed('prdProgress', data.prdProgress) || _changed('prdPrs', data.pullRequests?.length)) { renderPrdProgress(data.prdProgress); _cachePrdItems(data.prdProgress); }
80
80
  if (_changed('inbox', data.inbox)) renderInbox(data.inbox || []);
81
81
  if (_changed('projects', data.projects)) { cmdUpdateProjectList(data.projects || []); renderProjects(data.projects || []); }
82
+ // FRE banner — safe to call every tick (idempotent + cheap). Pass the full
83
+ // status payload so the runtime-CLI explainer reads autoMode.defaultCli from
84
+ // THIS tick rather than the previous one (window._lastStatus is set later).
85
+ if (typeof renderFre === 'function') {
86
+ try { renderFre(data); } catch { /* expected on first load */ }
87
+ }
82
88
  if (_changed('notes', data.notes)) renderNotes(data.notes);
83
89
  if (_changed('prd', [data.prd, data.prdProgress])) renderPrd(data.prd, data.prdProgress);
84
90
  if (_changed('prs', data.pullRequests)) renderPrs(data.pullRequests || []);
@@ -135,15 +135,27 @@ function openModal(i) {
135
135
  }
136
136
 
137
137
  function openAddPrModal() {
138
- const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p => {
138
+ const projects = (typeof cmdProjects !== 'undefined' ? cmdProjects : []) || [];
139
+ const projOpts = projects.map(p => {
139
140
  const name = typeof p === 'object' ? p.name : p;
140
141
  return '<option value="' + escapeHtml(name) + '">' + escapeHtml(name) + '</option>';
141
142
  }).join('');
142
143
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
143
144
 
145
+ // No-projects warning (W-mpehchnt000w46da) — surfaced when the operator hasn't
146
+ // added any project yet. We still allow the link (read-only tracking is valid),
147
+ // but warn that auto-fix / auto-review can't dispatch without a project worktree.
148
+ const noProjectsWarning = projects.length === 0
149
+ ? '<div id="pr-link-no-projects-warning" style="padding:8px 12px;background:rgba(210,153,34,0.15);border:1px solid rgba(210,153,34,0.3);border-radius:var(--radius-sm);color:var(--yellow);font-size:11px;line-height:1.5;margin-bottom:4px">' +
150
+ '<div style="font-weight:700;margin-bottom:2px">&#x26A0; No project configured.</div>' +
151
+ '<div style="color:var(--text)">This PR will be tracked, but auto-fix and auto-review cannot dispatch against it &mdash; agents need a project worktree to operate in. Add a project first (Projects tab) to enable automation.</div>' +
152
+ '</div>'
153
+ : '';
154
+
144
155
  document.getElementById('modal-title').textContent = 'Link Pull Request';
145
156
  document.getElementById('modal-body').innerHTML =
146
157
  '<div style="display:flex;flex-direction:column;gap:10px">' +
158
+ noProjectsWarning +
147
159
  '<label style="color:var(--text);font-size:var(--text-md)">PR URL <input id="pr-link-url" style="' + inputStyle + '" placeholder="https://github.com/org/repo/pull/123"></label>' +
148
160
  '<label style="color:var(--text);font-size:var(--text-md)">Title <input id="pr-link-title" style="' + inputStyle + '" placeholder="Short description (optional — auto-detected from URL)"></label>' +
149
161
  '<label style="color:var(--text);font-size:var(--text-md)">Project <select id="pr-link-project" style="' + inputStyle + '"><option value="">Auto-detect from URL (central if no unique match)</option>' + projOpts + '</select></label>' +
@@ -78,7 +78,10 @@
78
78
  <a class="sidebar-link" data-page="meetings" href="/meetings">Meetings</a>
79
79
  <a class="sidebar-link" data-page="engine" href="/engine">Engine</a>
80
80
  </nav>
81
- <div class="page-content" id="page-content"><!-- __PAGES__ --></div>
81
+ <div class="page-content" id="page-content">
82
+ <div id="fre-banner"></div>
83
+ <!-- __PAGES__ -->
84
+ </div>
82
85
  </div>
83
86
 
84
87
  <!-- Floating toast — lives outside .page divs so showToast() works on every page -->
@@ -34,7 +34,7 @@ function buildDashboardHtml() {
34
34
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
35
35
  'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
36
36
  'command-parser', 'command-input', 'command-center', 'command-history',
37
- 'modal', 'modal-qa', 'settings', 'qa', 'refresh'
37
+ 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
38
38
  ];
39
39
  let jsHtml = '';
40
40
  for (const f of jsFiles) {
package/dashboard.js CHANGED
@@ -38,7 +38,7 @@ const os = require('os');
38
38
  const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
39
39
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
40
40
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
41
- getEngineLog, getMetrics, getKnowledgeBaseEntries, getProjectGitStatus, timeSince,
41
+ getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
42
42
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
43
43
 
44
44
  // Dev vs binary differentiation. When two dashboards run side-by-side (npm
@@ -912,6 +912,16 @@ function _steeringDeliveryState(agentId) {
912
912
 
913
913
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
914
914
 
915
+ // W-mpetru8a000s123a — /api/plans cache. Dashboard auto-polls every 4s; the
916
+ // pre-cache cold path walked 4 directories (plans/, prd/, archive variants),
917
+ // sync-stat'd every file, and parsed/regex-scanned each .md → 2-3s blocking.
918
+ // 5s TTL means external .md edits surface within 5s. Mutation handlers must
919
+ // call invalidatePlansCache() for immediate visibility.
920
+ let _plansCache = null;
921
+ let _plansCacheTs = 0;
922
+ const PLANS_CACHE_TTL_MS = 5000;
923
+ function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
924
+
915
925
  // Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
916
926
  // Validates that the file stays within the expected directory to prevent path traversal.
917
927
  function resolvePlanPath(file) {
@@ -980,7 +990,7 @@ function buildDashboardHtml() {
980
990
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
981
991
  'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
982
992
  'command-parser', 'command-input', 'command-center', 'command-history',
983
- 'modal', 'modal-qa', 'settings', 'qa', 'refresh'
993
+ 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
984
994
  ];
985
995
  let jsHtml = '';
986
996
  for (const f of jsFiles) {
@@ -1640,6 +1650,7 @@ function _buildStatusSlowState() {
1640
1650
  ccCli: shared.resolveCcCli(CONFIG.engine),
1641
1651
  ccModel: shared.resolveCcModel(CONFIG.engine),
1642
1652
  ccEffort: CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort,
1653
+ defaultCli: shared.resolveAgentCli(null, CONFIG.engine),
1643
1654
  },
1644
1655
  initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
1645
1656
  installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
@@ -4278,6 +4289,7 @@ const server = http.createServer(async (req, res) => {
4278
4289
  });
4279
4290
  if (existingVerify) {
4280
4291
  invalidateStatusCache();
4292
+ invalidatePlansCache();
4281
4293
  return jsonReply(res, 200, { ok: true, verifyId: existingVerify.id });
4282
4294
  }
4283
4295
  }
@@ -4297,6 +4309,7 @@ const server = http.createServer(async (req, res) => {
4297
4309
  const verify = items.find(w => w.sourcePlan === body.file && w.itemType === 'verify');
4298
4310
  if (verify) {
4299
4311
  invalidateStatusCache();
4312
+ invalidatePlansCache();
4300
4313
  return jsonReply(res, 200, { ok: true, verifyId: verify.id });
4301
4314
  }
4302
4315
  }
@@ -4893,6 +4906,7 @@ const server = http.createServer(async (req, res) => {
4893
4906
 
4894
4907
  const planFile = 'manual-' + shared.uid() + '.json';
4895
4908
  safeWrite(path.join(PRD_DIR, planFile), manualPrd.plan);
4909
+ invalidatePlansCache();
4896
4910
  return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
4897
4911
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
4898
4912
  }
@@ -4948,6 +4962,7 @@ const server = http.createServer(async (req, res) => {
4948
4962
  } catch (e) { console.error('work item sync:', e.message); }
4949
4963
  }
4950
4964
 
4965
+ invalidatePlansCache();
4951
4966
  return jsonReply(res, 200, { ok: true, item, workItemSynced });
4952
4967
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
4953
4968
  }
@@ -4994,6 +5009,7 @@ const server = http.createServer(async (req, res) => {
4994
5009
  d.meta?.item?.sourcePlan === body.source && d.meta?.item?.id === body.itemId
4995
5010
  );
4996
5011
 
5012
+ invalidatePlansCache();
4997
5013
  return jsonReply(res, 200, { ok: true, cancelled });
4998
5014
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
4999
5015
  }
@@ -5338,7 +5354,7 @@ const server = http.createServer(async (req, res) => {
5338
5354
  }
5339
5355
 
5340
5356
  async function handleKnowledgeList(req, res) {
5341
- const entries = getKnowledgeBaseEntries();
5357
+ const entries = await getKnowledgeBaseEntries();
5342
5358
  const result = {};
5343
5359
  for (const cat of shared.KB_CATEGORIES) result[cat] = [];
5344
5360
  for (const e of entries) {
@@ -5352,7 +5368,11 @@ const server = http.createServer(async (req, res) => {
5352
5368
  // Source of truth: kb-sweep-state.json + PID liveness — the in-process
5353
5369
  // sweep moved to a detached runner so in-memory globals are no longer
5354
5370
  // authoritative (they die with the dashboard).
5371
+ // W-mpetru8a000s123a: yield event loop before readSweepLiveness (sync
5372
+ // process.kill + safeJson) so a single /api/knowledge handler can't
5373
+ // chain three sync blocks back-to-back during a stall window.
5355
5374
  try {
5375
+ await new Promise(r => setImmediate(r));
5356
5376
  const { readSweepLiveness } = require('./engine/kb-sweep');
5357
5377
  const liveness = readSweepLiveness({ entryCount: entries.length });
5358
5378
  if (liveness.inFlight && liveness.alive) {
@@ -5385,7 +5405,7 @@ const server = http.createServer(async (req, res) => {
5385
5405
  const {
5386
5406
  readSweepLiveness, staleGuardMs, KB_SWEEP_STATE_PATH, KB_SWEEP_LOG_PATH, KB_SWEEP_RUNNER_PATH,
5387
5407
  } = require('./engine/kb-sweep');
5388
- const entryCount = (queries.getKnowledgeBaseEntries() || []).length;
5408
+ const entryCount = ((await queries.getKnowledgeBaseEntries()) || []).length;
5389
5409
  const guardMs = staleGuardMs(entryCount);
5390
5410
 
5391
5411
  // Synchronous pre-claim BEFORE awaiting the body so a concurrent POST
@@ -5481,11 +5501,11 @@ const server = http.createServer(async (req, res) => {
5481
5501
  }
5482
5502
 
5483
5503
 
5484
- function handleKnowledgeSweepStatus(req, res) {
5504
+ async function handleKnowledgeSweepStatus(req, res) {
5485
5505
  // Source of truth = kb-sweep-state.json + PID liveness. Globals are gone —
5486
5506
  // the runner is detached, so its lifecycle is independent of this process.
5487
5507
  const { readSweepLiveness } = require('./engine/kb-sweep');
5488
- const entries = queries.getKnowledgeBaseEntries() || [];
5508
+ const entries = (await queries.getKnowledgeBaseEntries()) || [];
5489
5509
  const liveness = readSweepLiveness({ entryCount: entries.length });
5490
5510
  const diskState = safeJson(path.join(ENGINE_DIR, 'kb-sweep-state.json'));
5491
5511
  let inFlight = false;
@@ -5512,13 +5532,19 @@ const server = http.createServer(async (req, res) => {
5512
5532
  }
5513
5533
 
5514
5534
  async function handlePlansList(req, res) {
5535
+ const now = Date.now();
5536
+ if (_plansCache && (now - _plansCacheTs) < PLANS_CACHE_TTL_MS) {
5537
+ return jsonReply(res, 200, _plansCache);
5538
+ }
5539
+ const fsp = fs.promises;
5515
5540
  const dirs = [
5516
5541
  { dir: PLANS_DIR, archived: false },
5517
5542
  { dir: path.join(PLANS_DIR, 'archive'), archived: true },
5518
5543
  { dir: PRD_DIR, archived: false },
5519
5544
  { dir: path.join(PRD_DIR, 'archive'), archived: true },
5520
5545
  ];
5521
- // Load work items to check for completed plan-to-prd conversions
5546
+ // Load work items to check for completed plan-to-prd conversions.
5547
+ // safeJsonArr is sync but reads a single small file — leave as is.
5522
5548
  const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
5523
5549
  const completedPrdFiles = new Set(
5524
5550
  centralWi.filter(w => w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile)
@@ -5526,18 +5552,21 @@ const server = http.createServer(async (req, res) => {
5526
5552
  );
5527
5553
  const plans = [];
5528
5554
  for (const { dir, archived } of dirs) {
5529
- const allFiles = safeReadDir(dir).filter(f => f.endsWith('.json') || f.endsWith('.md'));
5530
- for (const f of allFiles) {
5555
+ const allFiles = (await fsp.readdir(dir).catch(() => []))
5556
+ .filter(f => f.endsWith('.json') || f.endsWith('.md'));
5557
+ const dirResults = await Promise.all(allFiles.map(async f => {
5531
5558
  const filePath = path.join(dir, f);
5532
- const content = safeRead(filePath) || '';
5533
- let updatedAt = '';
5534
- try { updatedAt = new Date(fs.statSync(filePath).mtimeMs).toISOString(); } catch { /* optional */ }
5559
+ const [content, stat] = await Promise.all([
5560
+ fsp.readFile(filePath, 'utf8').catch(() => ''),
5561
+ fsp.stat(filePath).catch(() => null),
5562
+ ]);
5563
+ const updatedAt = stat ? new Date(stat.mtimeMs).toISOString() : '';
5535
5564
  const isJson = f.endsWith('.json');
5536
5565
  if (isJson) {
5537
5566
  try {
5538
5567
  const plan = JSON.parse(content);
5539
5568
  const status = plan.status || 'active';
5540
- plans.push({
5569
+ return {
5541
5570
  file: f, format: 'prd', archived,
5542
5571
  project: plan.project || '',
5543
5572
  summary: plan.plan_summary || '',
@@ -5555,15 +5584,15 @@ const server = http.createServer(async (req, res) => {
5555
5584
  archiveReady: plan._archiveReady || false,
5556
5585
  archiveReadyAt: plan._archiveReadyAt || null,
5557
5586
  planStale: plan.planStale || false,
5558
- });
5559
- } catch { /* JSON parse fallback */ }
5587
+ };
5588
+ } catch { return null; /* JSON parse fallback */ }
5560
5589
  } else {
5561
5590
  const titleMatch = content.match(/^#\s+(?:Plan:\s*)?(.+)/m);
5562
5591
  const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)/m);
5563
5592
  const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
5564
5593
  const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
5565
5594
  const versionMatch = f.match(/-v(\d+)/);
5566
- plans.push({
5595
+ return {
5567
5596
  file: f, format: 'draft', archived,
5568
5597
  project: projectMatch ? projectMatch[1].trim() : '',
5569
5598
  summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
@@ -5577,11 +5606,14 @@ const server = http.createServer(async (req, res) => {
5577
5606
  requiresApproval: false,
5578
5607
  revisionFeedback: null,
5579
5608
  version: versionMatch ? parseInt(versionMatch[1]) : null,
5580
- });
5609
+ };
5581
5610
  }
5582
- }
5611
+ }));
5612
+ for (const r of dirResults) if (r) plans.push(r);
5583
5613
  }
5584
5614
  plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
5615
+ _plansCache = plans;
5616
+ _plansCacheTs = Date.now();
5585
5617
  return jsonReply(res, 200, plans);
5586
5618
  }
5587
5619
 
@@ -5616,6 +5648,7 @@ const server = http.createServer(async (req, res) => {
5616
5648
  }
5617
5649
 
5618
5650
  invalidateStatusCache();
5651
+ invalidatePlansCache();
5619
5652
  return jsonReply(res, 200, { ok: true, unarchivedSource });
5620
5653
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5621
5654
  }
@@ -5746,6 +5779,7 @@ const server = http.createServer(async (req, res) => {
5746
5779
  }
5747
5780
 
5748
5781
  invalidateStatusCache();
5782
+ invalidatePlansCache();
5749
5783
  return jsonReply(res, 200, { ok: true, status: 'approved', resumedWorkItems: resumed, diffAwareUpdate: diffAwareQueued });
5750
5784
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5751
5785
  }
@@ -5861,6 +5895,7 @@ const server = http.createServer(async (req, res) => {
5861
5895
  }, { defaultValue: { pending: [], active: [], completed: [] } });
5862
5896
 
5863
5897
  invalidateStatusCache();
5898
+ invalidatePlansCache();
5864
5899
  return jsonReply(res, 200, { ok: true, status: 'paused', resetWorkItems: reset });
5865
5900
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5866
5901
  }
@@ -5894,6 +5929,7 @@ const server = http.createServer(async (req, res) => {
5894
5929
  });
5895
5930
  if (!queueResult.queued) return jsonReply(res, 200, { ok: true, alreadyQueued: true, id: queueResult.id });
5896
5931
  invalidateStatusCache();
5932
+ invalidatePlansCache();
5897
5933
  return jsonReply(res, 200, { ok: true, id: queueResult.id });
5898
5934
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5899
5935
  }
@@ -5912,6 +5948,7 @@ const server = http.createServer(async (req, res) => {
5912
5948
  return data;
5913
5949
  }, { defaultValue: {} });
5914
5950
 
5951
+ invalidatePlansCache();
5915
5952
  return jsonReply(res, 200, { ok: true, status: 'rejected' });
5916
5953
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
5917
5954
  }
@@ -6039,6 +6076,7 @@ const server = http.createServer(async (req, res) => {
6039
6076
  }
6040
6077
 
6041
6078
  invalidateStatusCache();
6079
+ invalidatePlansCache();
6042
6080
  return jsonReply(res, 200, { ok: true, cleanedWorkItems: cleaned, cleanedDispatches: dispatchCleaned });
6043
6081
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
6044
6082
  }
@@ -6114,6 +6152,7 @@ const server = http.createServer(async (req, res) => {
6114
6152
  } catch (e) { console.error('plan worktree cleanup:', e.message); }
6115
6153
 
6116
6154
  invalidateStatusCache();
6155
+ invalidatePlansCache();
6117
6156
  const payload = { ok: true, archived: body.file, archivedSource, cancelledItems };
6118
6157
  if (archiveWarnings.length > 0) payload.warnings = archiveWarnings;
6119
6158
  return jsonReply(res, 200, payload);
@@ -6148,6 +6187,7 @@ const server = http.createServer(async (req, res) => {
6148
6187
  }
6149
6188
 
6150
6189
  invalidateStatusCache();
6190
+ invalidatePlansCache();
6151
6191
  return jsonReply(res, 200, { ok: true, unarchivedSource });
6152
6192
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
6153
6193
  }
@@ -6179,6 +6219,7 @@ const server = http.createServer(async (req, res) => {
6179
6219
  planFile: body.file,
6180
6220
  });
6181
6221
  });
6222
+ invalidatePlansCache();
6182
6223
  return jsonReply(res, 200, { ok: true, status: 'revision-requested', workItemId: id });
6183
6224
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
6184
6225
  }
@@ -10070,6 +10111,12 @@ if (require.main === module) {
10070
10111
  console.log(` Projects: ${PROJECTS.map(p => `${p.name} (${p.localPath})`).join(', ')}`);
10071
10112
  console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
10072
10113
 
10114
+ // W-mpetru8a000s123a — warm the async KB cache so synchronous callers
10115
+ // (getWorkItems) see real data on first /api/status instead of an empty
10116
+ // snapshot. Fire-and-forget; tolerant of warming failure.
10117
+ Promise.resolve(queries.getKnowledgeBaseEntries())
10118
+ .catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
10119
+
10073
10120
  // Auto-open the browser unless suppressed. `minions restart` and the
10074
10121
  // upgrade path set MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the
10075
10122
  // open itself after observing whether an existing tab reconnected.
package/engine/cli.js CHANGED
@@ -895,6 +895,21 @@ const commands = {
895
895
  }
896
896
  })();
897
897
 
898
+ // W-mpetru8a000s123a — warm the async KB cache so synchronous callers
899
+ // (getWorkItems, getKnowledgeBaseIndex, playbook render) see real data
900
+ // on first read instead of the empty snapshot. Fire-and-forget; the cache
901
+ // updates as soon as the scan resolves and any inflight async caller
902
+ // shares the same promise.
903
+ (function warmKnowledgeBaseCache() {
904
+ try {
905
+ const queries = require('./queries');
906
+ Promise.resolve(queries.getKnowledgeBaseEntries())
907
+ .catch(err => e.log('warn', `KB cache warm failed: ${err && err.message}`));
908
+ } catch (err) {
909
+ e.log('warn', `KB cache warm setup failed: ${err.message}`);
910
+ }
911
+ })();
912
+
898
913
  // Initial tick
899
914
  e.tick();
900
915
 
@@ -484,7 +484,7 @@ async function _runKbSweepImpl(opts = {}) {
484
484
  };
485
485
  const t0 = Date.now();
486
486
 
487
- const entries = queries.getKnowledgeBaseEntries();
487
+ const entries = await queries.getKnowledgeBaseEntries();
488
488
  if (entries.length < 2) { summary.summary = 'nothing to sweep (< 2 entries)'; summary.durationMs = Date.now() - t0; return summary; }
489
489
 
490
490
  const requestPinned = Array.isArray(opts.pinnedKeys)
@@ -535,7 +535,7 @@ async function _runKbSweepImpl(opts = {}) {
535
535
  summary.sweptArchivePruned = _pruneOldSwept();
536
536
 
537
537
  // Final tallies — re-walk surviving entries for accurate bytesAfter
538
- const finalEntries = queries.getKnowledgeBaseEntries();
538
+ const finalEntries = await queries.getKnowledgeBaseEntries();
539
539
  for (const e of finalEntries) {
540
540
  if (pinned.has(`knowledge/${e.cat}/${e.file}`)) continue;
541
541
  const fp = path.join(KB_DIR, e.cat, e.file);
package/engine/queries.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const fs = require('fs');
8
+ const fsp = require('fs').promises;
8
9
  const path = require('path');
9
10
  const os = require('os');
10
11
  const shared = require('./shared');
@@ -1058,35 +1059,52 @@ function getCommandIndex(config) {
1058
1059
 
1059
1060
  // ── Knowledge Base ──────────────────────────────────────────────────────────
1060
1061
 
1061
- let _kbCache = null;
1062
+ // W-mpetru8a000s123a async KB scan + stale-while-revalidate cache.
1063
+ // Async fs prevents event-loop stalls on /api/knowledge cold-cache hits
1064
+ // (previously ~11s of blocking readFileSync/statSync). Sync callers
1065
+ // (getWorkItems, getKnowledgeBaseIndex, playbook render) use the in-memory
1066
+ // snapshot via getKnowledgeBaseEntriesSnapshot() to avoid forcing async
1067
+ // propagation through ~17 callers of getWorkItems.
1068
+ let _kbCache = null; // last good snapshot — never nulled by invalidate
1062
1069
  let _kbCacheTs = 0;
1070
+ let _kbCacheStale = true; // invalidate marks stale; snapshot kept for sync readers
1071
+ let _kbRefreshPromise = null; // in-flight scan dedupe
1063
1072
  const KB_CACHE_TTL = 30000; // 30s — KB changes infrequently
1064
1073
 
1065
1074
  function invalidateKnowledgeBaseCache() {
1066
- _kbCache = null;
1075
+ _kbCacheStale = true;
1067
1076
  _kbCacheTs = 0;
1068
1077
  }
1069
1078
 
1070
- function getKnowledgeBaseEntries() {
1071
- const now = Date.now();
1072
- if (_kbCache && (now - _kbCacheTs) < KB_CACHE_TTL) return _kbCache;
1079
+ /**
1080
+ * Synchronous snapshot — returns last-known KB entries from memory, never
1081
+ * touches disk. Returns [] only until the first async getKnowledgeBaseEntries()
1082
+ * resolves. Used by sync callers (getWorkItems, getKnowledgeBaseIndex,
1083
+ * playbook render) that historically called the sync version.
1084
+ */
1085
+ function getKnowledgeBaseEntriesSnapshot() {
1086
+ return Array.isArray(_kbCache) ? _kbCache : [];
1087
+ }
1073
1088
 
1089
+ async function _scanKnowledgeBase() {
1074
1090
  const entries = [];
1075
1091
  for (const cat of KB_CATEGORIES) {
1076
1092
  const catDir = path.join(KNOWLEDGE_DIR, cat);
1077
- const files = safeReadDir(catDir).filter(f => f.endsWith('.md'));
1078
- for (const f of files) {
1093
+ const files = (await fsp.readdir(catDir).catch(() => [])).filter(f => f.endsWith('.md'));
1094
+ const fileResults = await Promise.all(files.map(async f => {
1079
1095
  const filePath = path.join(catDir, f);
1080
- const content = safeRead(filePath) || '';
1096
+ const [content, stat] = await Promise.all([
1097
+ fsp.readFile(filePath, 'utf8').catch(() => ''),
1098
+ fsp.stat(filePath).catch(() => null),
1099
+ ]);
1081
1100
  const titleMatch = content.match(/^#\s+(.+)/m);
1082
1101
  const title = titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, '');
1083
1102
  const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
1084
1103
  const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/) || content.match(/^date:\s*(\d{4}-\d{2}-\d{2})$/m);
1085
1104
  const sourceMatch = content.match(/^source:\s*(.+)/m);
1086
- let sortTs = 0;
1087
- try { sortTs = fs.statSync(filePath).mtimeMs || 0; } catch {}
1105
+ const sortTs = (stat && stat.mtimeMs) || 0;
1088
1106
  const displayDate = dateMatch ? dateMatch[1] : (sortTs ? new Date(sortTs).toISOString().slice(0, 10) : '');
1089
- entries.push({
1107
+ return {
1090
1108
  cat, file: f, title,
1091
1109
  agent: agentMatch ? agentMatch[1] : '',
1092
1110
  date: displayDate,
@@ -1094,22 +1112,36 @@ function getKnowledgeBaseEntries() {
1094
1112
  source: sourceMatch ? sourceMatch[1].trim() : '',
1095
1113
  preview: content.slice(0, 200),
1096
1114
  size: content.length,
1097
- });
1098
- }
1115
+ };
1116
+ }));
1117
+ entries.push(...fileResults);
1099
1118
  }
1100
1119
  entries.sort((a, b) =>
1101
1120
  (b.sortTs || 0) - (a.sortTs || 0) ||
1102
1121
  (b.date || '').localeCompare(a.date || '') ||
1103
1122
  a.title.localeCompare(b.title)
1104
1123
  );
1105
- _kbCache = entries;
1106
- _kbCacheTs = now;
1107
1124
  return entries;
1108
1125
  }
1109
1126
 
1127
+ async function getKnowledgeBaseEntries() {
1128
+ const now = Date.now();
1129
+ if (!_kbCacheStale && _kbCache && (now - _kbCacheTs) < KB_CACHE_TTL) return _kbCache;
1130
+ if (_kbRefreshPromise) return _kbRefreshPromise;
1131
+ _kbRefreshPromise = _scanKnowledgeBase()
1132
+ .then(entries => {
1133
+ _kbCache = entries;
1134
+ _kbCacheTs = Date.now();
1135
+ _kbCacheStale = false;
1136
+ return _kbCache;
1137
+ })
1138
+ .finally(() => { _kbRefreshPromise = null; });
1139
+ return _kbRefreshPromise;
1140
+ }
1141
+
1110
1142
  function getKnowledgeBaseIndex() {
1111
1143
  try {
1112
- const entries = getKnowledgeBaseEntries();
1144
+ const entries = getKnowledgeBaseEntriesSnapshot();
1113
1145
  if (entries.length === 0) return '';
1114
1146
  let index = '## Knowledge Base Reference\n\n';
1115
1147
  index += 'Deep-reference docs from past work. Read the file if you need detail.\n\n';
@@ -1227,8 +1259,9 @@ function getWorkItems(config) {
1227
1259
  const _agentDirCache = {};
1228
1260
  const _inboxFiles = safeReadDir(INBOX_DIR);
1229
1261
  const _archiveFiles = safeReadDir(ARCHIVE_DIR);
1230
- // Use cached KB entries (includes source frontmatter field)
1231
- const _kbEntries = getKnowledgeBaseEntries();
1262
+ // Use snapshot sync access; cold start before any async warm returns [].
1263
+ // Best-effort enrichment for work item _artifacts.notes, not correctness-critical.
1264
+ const _kbEntries = getKnowledgeBaseEntriesSnapshot();
1232
1265
  for (const item of allItems) {
1233
1266
  const arts = {};
1234
1267
  const agentId = item.dispatched_to || item.agent;
@@ -1754,7 +1787,7 @@ module.exports = {
1754
1787
  collectCommandFiles, getCommandIndex,
1755
1788
 
1756
1789
  // Knowledge base
1757
- getKnowledgeBaseEntries, getKnowledgeBaseIndex,
1790
+ getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
1758
1791
 
1759
1792
  // Work items & PRD
1760
1793
  getWorkItems, getPrdInfo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2002",
3
+ "version": "0.1.2004",
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"