@yemi33/minions 0.1.2236 → 0.1.2237

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.
@@ -147,7 +147,7 @@ const RENDER_VERSIONS = {
147
147
  dispatch: 2,
148
148
  engineLog: 2,
149
149
  metrics: 1,
150
- workItems: 3,
150
+ workItems: 4,
151
151
  skills: 1,
152
152
  commands: 1,
153
153
  mcpServers: 1,
@@ -238,6 +238,10 @@ function wiRow(item) {
238
238
  })() +
239
239
  '</td>' +
240
240
  '<td style="white-space:nowrap">' +
241
+ // W-mqqa76pi — "View live output" opens the agent slide-in panel (live-output
242
+ // tab auto-selected when the agent is working). Only for genuinely active
243
+ // dispatches with a resolvable assigned agent id.
244
+ ((item.status === 'dispatched' && (item.dispatched_to || item.agent)) ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();openArtifact(\'agent\',\'' + escapeHtml(item.dispatched_to || item.agent) + '\')" title="View live output">&#x1F4FA;</button>' : '') +
241
245
  ((item.status === 'pending' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
242
246
  ((item.status === 'done' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
243
247
  ((item.status === 'done' || item.status === 'failed') && !item._humanFeedback ? '<button class="btn-action" style="margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Give feedback">&#x1F44D;&#x1F44E;</button>' : (item._humanFeedback ? '<span style="font-size:var(--text-xs)" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span> ' : '')) +
@@ -699,6 +703,14 @@ function _wiRenderDetail(item) {
699
703
  return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="See &quot;Why this is blocked&quot; below">&#x26A0; Needs attention</span>';
700
704
  })() +
701
705
  '</div>';
706
+ // W-mqqa76pi — Prominent "View live output" button for an actively-dispatched
707
+ // WI. Opens the agent slide-in panel via openArtifact('agent', id), which
708
+ // resets the modal stack and selects the live-output tab when the agent is
709
+ // working. Gated on status==='dispatched' AND a resolvable assigned agent id.
710
+ if (item.status === 'dispatched' && (item.dispatched_to || item.agent)) {
711
+ var _wiLiveAgentId = item.dispatched_to || item.agent;
712
+ html += '<div style="margin-bottom:12px"><button class="pr-pager-btn" style="font-size:var(--text-base);padding:4px 12px;color:var(--green);border-color:var(--green)" onclick="openArtifact(\'agent\',\'' + escapeHtml(_wiLiveAgentId) + '\')" title="View live output">&#x1F4FA; View live output</button></div>';
713
+ }
702
714
  // Work item id — shown in the modal body (not just the title) so operators can
703
715
  // read/copy the canonical id (W-… or sched-…) when deeplinking in from a note.
704
716
  html += field('ID', '<code style="font-size:var(--text-sm);background:var(--surface2);padding:2px 6px;border-radius:var(--radius-sm)">' + escapeHtml(item.id || '—') + '</code>');
package/dashboard.js CHANGED
@@ -1977,6 +1977,61 @@ setInterval(() => checkNpmVersion().catch(() => {}), _getVersionCheckInterval())
1977
1977
  let _diskVersionCache = null;
1978
1978
  let _diskVersionCacheTs = 0;
1979
1979
  const DISK_VERSION_TTL = 60000; // re-check every 60s
1980
+
1981
+ // Read the short HEAD commit by parsing `.git` directly — NO `git` subprocess.
1982
+ // The old `execSync('git rev-parse --short HEAD', { timeout: 5000 })` spawned a
1983
+ // synchronous child process on the dashboard's single event loop; under
1984
+ // concurrent agent git activity (worktree add/merge holding repo locks) that
1985
+ // subprocess could take its full 5s timeout, and because getDiskVersion runs
1986
+ // inside the /api/status rebuild, every in-flight poll blocked for that whole
1987
+ // window. Reading `.git/HEAD` + the resolved ref (loose ref, then packed-refs
1988
+ // fallback) is a couple of tiny synchronous file reads with zero process spawn.
1989
+ // Returns { commit, isGitRepo }: commit is the 7-char short SHA or null.
1990
+ function _readGitHeadShort(repoDir) {
1991
+ try {
1992
+ const gitPath = path.join(repoDir, '.git');
1993
+ let gitDir = gitPath;
1994
+ const st = fs.statSync(gitPath); // throws if no `.git` → caught → not a repo
1995
+ if (st.isFile()) {
1996
+ // Linked worktree: `.git` is a file whose first line reads `gitdir: <abs>`.
1997
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(gitPath, 'utf8'));
1998
+ if (!m) return { commit: null, isGitRepo: false };
1999
+ gitDir = path.isAbsolute(m[1]) ? m[1] : path.resolve(repoDir, m[1]);
2000
+ }
2001
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
2002
+ let sha = null;
2003
+ const refM = /^ref:\s*(.+?)\s*$/.exec(head);
2004
+ if (refM) {
2005
+ const ref = refM[1];
2006
+ try { sha = fs.readFileSync(path.join(gitDir, ref), 'utf8').trim() || null; } catch {}
2007
+ if (!sha) {
2008
+ // packed-refs fallback (git gc packs loose refs). For the main repo
2009
+ // gitDir IS the common gitdir, so packed-refs lives directly under it.
2010
+ try {
2011
+ const line = fs.readFileSync(path.join(gitDir, 'packed-refs'), 'utf8')
2012
+ .split('\n').find(l => l.endsWith(' ' + ref));
2013
+ if (line) sha = line.slice(0, 40);
2014
+ } catch {}
2015
+ }
2016
+ } else if (/^[0-9a-f]{40}$/i.test(head)) {
2017
+ sha = head; // detached HEAD: the SHA is written directly into HEAD
2018
+ }
2019
+ return { commit: sha ? sha.slice(0, 7) : null, isGitRepo: true };
2020
+ } catch {
2021
+ return { commit: null, isGitRepo: false };
2022
+ }
2023
+ }
2024
+
2025
+ // Two git short-SHA abbreviations name the same commit when one is a prefix of
2026
+ // the other. `git rev-parse --short` picks the minimum-unique length (often 8),
2027
+ // while _readGitHeadShort emits a fixed 7 — a strict `!==` between them would
2028
+ // false-positive into a spurious "version stale" banner. Prefix comparison is
2029
+ // the robust check across abbreviation-length differences.
2030
+ function _commitsDiffer(a, b) {
2031
+ if (!a || !b) return false;
2032
+ return !(a.startsWith(b) || b.startsWith(a));
2033
+ }
2034
+
1980
2035
  function getDiskVersion() {
1981
2036
  const now = Date.now();
1982
2037
  if (_diskVersionCache && (now - _diskVersionCacheTs) < DISK_VERSION_TTL) return _diskVersionCache;
@@ -1990,10 +2045,11 @@ function getDiskVersion() {
1990
2045
  if (!diskVersion) {
1991
2046
  try { diskVersion = require('@yemi33/minions/package.json').version; } catch {}
1992
2047
  }
1993
- let diskCommit = null;
1994
- let isGitRepo = false;
1995
- // Prefer git (authoritative for repo-based dev), fall back to .minions-commit (installed copies)
1996
- try { diskCommit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: MINIONS_DIR, encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); isGitRepo = true; } catch {}
2048
+ // Prefer git (authoritative for repo-based dev), fall back to .minions-commit (installed copies).
2049
+ // Parse `.git` directly instead of spawning `git rev-parse` — see _readGitHeadShort.
2050
+ const head = _readGitHeadShort(MINIONS_DIR);
2051
+ let diskCommit = head.commit;
2052
+ let isGitRepo = head.isGitRepo;
1997
2053
  if (!diskCommit) {
1998
2054
  try { diskCommit = fs.readFileSync(path.join(MINIONS_DIR, '.minions-commit'), 'utf8').trim() || null; } catch {}
1999
2055
  }
@@ -2450,9 +2506,9 @@ function _buildStatusSlowState() {
2450
2506
  const engine = getEngineState();
2451
2507
  const { diskVersion, diskCommit, isGitRepo } = getDiskVersion();
2452
2508
  const engineStale = !!(engine.codeVersion && diskVersion && engine.codeVersion !== diskVersion) ||
2453
- !!(engine.codeCommit && diskCommit && engine.codeCommit !== diskCommit);
2509
+ _commitsDiffer(engine.codeCommit, diskCommit);
2454
2510
  const dashboardStale = !!(diskVersion && _dashboardVersion.codeVersion && diskVersion !== _dashboardVersion.codeVersion) ||
2455
- !!(diskCommit && _dashboardVersion.codeCommit && diskCommit !== _dashboardVersion.codeCommit);
2511
+ _commitsDiffer(diskCommit, _dashboardVersion.codeCommit);
2456
2512
  return {
2457
2513
  running: engine.codeVersion || null,
2458
2514
  runningCommit: engine.codeCommit || null,
@@ -3056,17 +3112,34 @@ async function _handleStatusRequest(req, res) {
3056
3112
  try {
3057
3113
  _recordDashboardBrowserPresenceFromRequest(req);
3058
3114
 
3059
- // Ensure cache is fresh + yields the event loop mid-rebuild so CC SSE
3060
- // heartbeats keep flowing during slow rebuilds. Synchronous getStatus()
3061
- // still works for SSE-periodic-push callers that can't await.
3062
- try { await refreshStatusAsync(); } catch { /* fall through getStatusJson will sync-rebuild */ }
3063
-
3064
- // Race-guard fallback: if refresh discarded its result because
3065
- // invalidateStatusCache() fired mid-rebuild, _statusCache is still null.
3066
- // Trigger a synchronous rebuild here so the ETag we compute reflects
3067
- // freshly-built post-invalidate state, not the stale pre-invalidate one.
3068
- if (!_statusCache) {
3069
- try { getStatus(); } catch { /* getStatusJson below will retry */ }
3115
+ // Stale-while-revalidate (perf, W-status-swr). Two staleness modes exist:
3116
+ // HARD invalidation invalidateStatusCache() sets _statusCache = null
3117
+ // because a mutation MUST be reflected before we answer. There is
3118
+ // nothing valid to serve, so we await one rebuild (cold path below).
3119
+ // • SOFT staleness — the event-version / mtime signal advanced (e.g. an
3120
+ // agent emitted a state event) but _statusCache still holds a valid,
3121
+ // seconds-old snapshot. Under active-agent load this fires on nearly
3122
+ // every 4s poll. Awaiting the rebuild here is what stalled every
3123
+ // in-flight poll for 6–15s, because the single-flight rebuild runs
3124
+ // synchronous fs/git probes to completion before resolving.
3125
+ // When we have ANY snapshot, never block the request: kick the refresh in
3126
+ // the background (its result lands in _statusCache and bumps the ETag for
3127
+ // the NEXT poll) and serve the current cache now. A 4s-old envelope for a
3128
+ // 4s poller is fine; a 15s-blocked request is not.
3129
+ if (_statusCache) {
3130
+ Promise.resolve().then(refreshStatusAsync).catch(() => { /* next poll + SSE push retry */ });
3131
+ } else {
3132
+ // Cold start or post-hard-invalidation: nothing to serve → await one
3133
+ // rebuild. refreshStatusAsync yields the event loop mid-rebuild so CC
3134
+ // SSE heartbeats keep flowing during a slow build.
3135
+ try { await refreshStatusAsync(); } catch { /* fall through — getStatusJson will sync-rebuild */ }
3136
+ // Race-guard fallback: if refresh discarded its result because
3137
+ // invalidateStatusCache() fired mid-rebuild, _statusCache is still null.
3138
+ // Trigger a synchronous rebuild here so the ETag we compute reflects
3139
+ // freshly-built post-invalidate state, not the stale pre-invalidate one.
3140
+ if (!_statusCache) {
3141
+ try { getStatus(); } catch { /* getStatusJson below will retry */ }
3142
+ }
3070
3143
  }
3071
3144
 
3072
3145
  // ETag = monotonic cache version. Bumped on every successful rebuild and
@@ -12530,9 +12603,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12530
12603
  const { diskVersion, diskCommit, isGitRepo } = getDiskVersion();
12531
12604
  const engine = getEngineState();
12532
12605
  const engineStale = !!(engine.codeVersion && diskVersion && engine.codeVersion !== diskVersion) ||
12533
- !!(engine.codeCommit && diskCommit && engine.codeCommit !== diskCommit);
12606
+ _commitsDiffer(engine.codeCommit, diskCommit);
12534
12607
  const dashboardStale = !!(diskVersion && _dashboardVersion.codeVersion && diskVersion !== _dashboardVersion.codeVersion) ||
12535
- !!(diskCommit && _dashboardVersion.codeCommit && diskCommit !== _dashboardVersion.codeCommit);
12608
+ _commitsDiffer(diskCommit, _dashboardVersion.codeCommit);
12536
12609
  return jsonReply(res, 200, {
12537
12610
  current: diskVersion,
12538
12611
  currentCommit: diskCommit,
@@ -14060,6 +14133,11 @@ module.exports = {
14060
14133
  _setStatusRefreshHook,
14061
14134
  _resetStatusCacheForTesting,
14062
14135
  _ifNoneMatchHasEtag,
14136
+ // W-status-swr — exported for direct unit tests of the subprocess-free git
14137
+ // HEAD parser and the prefix-tolerant short-SHA comparison. No production
14138
+ // caller imports these; they are test seams.
14139
+ _readGitHeadShort,
14140
+ _commitsDiffer,
14063
14141
  // Exported for the engine-stale-two-signal test (W-mpof1xxe000ac689) — the
14064
14142
  // staleness verdict it stamps on engine.heartbeatStale is the contract under
14065
14143
  // test. No production caller imports this; it is a test seam.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2236",
3
+ "version": "0.1.2237",
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"