@yemi33/minions 0.1.1769 → 0.1.1771

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,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1771 (2026-05-07)
4
+
5
+ ### Features
6
+ - clean merged pr local branches (#2165)
7
+
8
+ ## 0.1.1770 (2026-05-07)
9
+
10
+ ### Fixes
11
+ - open dashboard on update cold start
12
+ - silence generated Claude defaults warning
13
+ - drop TTL and turn-count cap so users can resume any time
14
+
3
15
  ## 0.1.1769 (2026-05-07)
4
16
 
5
17
  ### Fixes
package/bin/minions.js CHANGED
@@ -29,6 +29,7 @@ const { spawn, spawnSync, execSync } = require('child_process');
29
29
 
30
30
  const PKG_ROOT = path.resolve(__dirname, '..');
31
31
  const DASH_PORT = 7331;
32
+ const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
32
33
 
33
34
  /** Returns PIDs (as strings) of processes LISTENING on `port`. Empty on no match
34
35
  * or when the platform tool (netstat/findstr/lsof) is unavailable. */
@@ -60,6 +61,18 @@ function killByPort(port) {
60
61
 
61
62
  const isPortListening = (port) => getListeningPids(port).length > 0;
62
63
 
64
+ function hasRecentDashboardBrowserTab(minionsHome, now = Date.now()) {
65
+ try {
66
+ const fp = path.join(minionsHome, 'engine', 'dashboard-browser.json');
67
+ const state = JSON.parse(fs.readFileSync(fp, 'utf8'));
68
+ const tabs = state && state.tabs && typeof state.tabs === 'object' ? state.tabs : {};
69
+ return Object.values(tabs).some(tab => {
70
+ const lastSeen = Number(tab && tab.lastSeen);
71
+ return Number.isFinite(lastSeen) && now - lastSeen <= DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS;
72
+ });
73
+ } catch { return false; }
74
+ }
75
+
63
76
  /**
64
77
  * Read the engine's recorded PID from engine/control.json. Returns null if
65
78
  * the file is missing/corrupt or the PID isn't a positive integer.
@@ -118,7 +131,7 @@ function killMinionsProcesses(patterns) {
118
131
  }
119
132
 
120
133
  /** Spawn a detached dashboard. When `suppressOpen` is true, the new dashboard
121
- * skips its auto-open of the browser — the existing tab will SSE-reconnect. */
134
+ * skips its auto-open of the browser — the existing tab will reconnect. */
122
135
  function spawnDashboard(suppressOpen) {
123
136
  const env = suppressOpen ? { ...process.env, MINIONS_NO_AUTO_OPEN: '1' } : process.env;
124
137
  const proc = spawn(process.execPath, [path.join(MINIONS_HOME, 'dashboard.js')], {
@@ -397,9 +410,11 @@ function init() {
397
410
  if (isUpgrade && skipStart) return;
398
411
 
399
412
  // Auto-start on fresh install; direct force-upgrade restarts automatically.
400
- // Probe before kill so we can suppress the new dashboard's auto-open when an
401
- // existing tab is already live (it'll SSE-reconnect to the new dashboard).
413
+ // Probe before kill so we suppress browser auto-open only when a browser tab
414
+ // was recently polling the dashboard. A bare/orphan dashboard process on the
415
+ // port is not enough; cold starts should still open the UI.
402
416
  const dashWasUp = isPortListening(DASH_PORT);
417
+ const suppressDashboardOpen = dashWasUp && hasRecentDashboardBrowserTab(MINIONS_HOME);
403
418
  if (isUpgrade) {
404
419
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
405
420
  // Free the dashboard port too — without this the new dashboard EADDRINUSE-dies
@@ -415,7 +430,7 @@ function init() {
415
430
  engineProc.unref();
416
431
  console.log(` Engine started (PID: ${engineProc.pid})`);
417
432
 
418
- const dashProc = spawnDashboard(dashWasUp);
433
+ const dashProc = spawnDashboard(suppressDashboardOpen);
419
434
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
420
435
  console.log(` Dashboard: http://localhost:${DASH_PORT}`);
421
436
 
@@ -670,9 +685,11 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
670
685
  // `--cli` / `--model` flags forward to `engine.js start` so the runtime
671
686
  // fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
672
687
  ensureInstalled();
673
- // Probe before kill so we can suppress the new dashboard's auto-open when an
674
- // existing tab is already live (it'll SSE-reconnect to the new dashboard).
688
+ // Probe before kill so we suppress browser auto-open only when a browser tab
689
+ // was recently polling the dashboard. A bare/orphan dashboard process on the
690
+ // port is not enough; cold starts should still open the UI.
675
691
  const dashWasUp = isPortListening(DASH_PORT);
692
+ const suppressDashboardOpen = dashWasUp && hasRecentDashboardBrowserTab(MINIONS_HOME);
676
693
  // Layered kill — each step is best-effort, layered so the next still runs if
677
694
  // one fails. Goal: the old engine is gone before we spawn a new one, even if
678
695
  // PowerShell is unavailable, the engine is hung, or its cmdline doesn't match.
@@ -691,7 +708,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
691
708
  });
692
709
  engineProc.unref();
693
710
  console.log(`\n Engine started (PID: ${engineProc.pid})`);
694
- const dashProc = spawnDashboard(dashWasUp);
711
+ const dashProc = spawnDashboard(suppressDashboardOpen);
695
712
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
696
713
  console.log(` Dashboard: http://localhost:${DASH_PORT}\n`);
697
714
  } else if (cmd === 'nuke') {
@@ -4,6 +4,17 @@ let inboxData = [];
4
4
  let agentData = [];
5
5
  let currentAgentId = null;
6
6
  let currentTab = 'thought-process';
7
+ const DASHBOARD_TAB_ID = (function() {
8
+ try {
9
+ var existing = sessionStorage.getItem('minions-dashboard-tab-id');
10
+ if (existing) return existing;
11
+ var id = 'tab-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
12
+ sessionStorage.setItem('minions-dashboard-tab-id', id);
13
+ return id;
14
+ } catch {
15
+ return 'tab-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
16
+ }
17
+ })();
7
18
 
8
19
  // Sidebar page navigation — URL-routed
9
20
  function getPageFromUrl() {
@@ -87,6 +98,37 @@ function prunePrdRequeueState(workItems) {
87
98
  }
88
99
  }
89
100
 
101
+ function _dashboardPresencePayload(closed) {
102
+ return JSON.stringify({
103
+ tabId: DASHBOARD_TAB_ID,
104
+ closed: !!closed,
105
+ url: location.pathname + location.search + location.hash,
106
+ visibility: document.visibilityState || '',
107
+ });
108
+ }
109
+
110
+ function _sendDashboardPresence(closed) {
111
+ var payload = _dashboardPresencePayload(closed);
112
+ try {
113
+ if (navigator.sendBeacon) {
114
+ var blob = new Blob([payload], { type: 'application/json' });
115
+ if (navigator.sendBeacon('/api/browser-presence', blob)) return;
116
+ }
117
+ } catch {}
118
+ try {
119
+ fetch('/api/browser-presence', {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: payload,
123
+ keepalive: true,
124
+ }).catch(function() {});
125
+ } catch {}
126
+ }
127
+
128
+ window.addEventListener('pagehide', function() { _sendDashboardPresence(true); });
129
+ window.addEventListener('beforeunload', function() { _sendDashboardPresence(true); });
130
+ document.addEventListener('visibilitychange', function() { _sendDashboardPresence(false); });
131
+
90
132
  function rerenderPrdFromCache() {
91
133
  if (!window._lastStatus || !window._lastStatus.prdProgress) return;
92
134
  renderPrdProgress(window._lastStatus.prdProgress);
@@ -100,6 +142,13 @@ function safeFetch(url, opts) {
100
142
  var controller = new AbortController();
101
143
  var timer = setTimeout(function() { controller.abort(); }, timeout);
102
144
  var fetchOpts = Object.assign({}, opts, { signal: controller.signal });
145
+ if (typeof url === 'string' && url.indexOf('/api/') === 0) {
146
+ var headers = Object.assign({}, fetchOpts.headers || {});
147
+ headers['X-Minions-Dashboard-Tab'] = DASHBOARD_TAB_ID;
148
+ headers['X-Minions-Dashboard-Url'] = location.pathname + location.search + location.hash;
149
+ headers['X-Minions-Dashboard-Visibility'] = document.visibilityState || '';
150
+ fetchOpts.headers = headers;
151
+ }
103
152
  delete fetchOpts.timeout;
104
153
  return fetch(url, fetchOpts).finally(function() { clearTimeout(timer); });
105
154
  }
package/dashboard.js CHANGED
@@ -60,6 +60,8 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
60
60
  const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
61
61
  let CONFIG = queries.getConfig();
62
62
  let PROJECTS = _getProjects(CONFIG);
63
+ const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
64
+ const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
63
65
 
64
66
  function ensureConfiguredProjectStateFiles() {
65
67
  for (const p of PROJECTS) {
@@ -571,6 +573,66 @@ function _trackSseClient(clientSet, req, res, { heartbeatMs = SSE_CLIENT_HEARTBE
571
573
  return cleanup;
572
574
  }
573
575
 
576
+ function _normalizeDashboardTabId(value) {
577
+ const id = typeof value === 'string' ? value.trim() : '';
578
+ return /^[A-Za-z0-9._:-]{1,96}$/.test(id) ? id : null;
579
+ }
580
+
581
+ function _pruneDashboardBrowserTabs(tabs, now = Date.now()) {
582
+ for (const [id, tab] of Object.entries(tabs || {})) {
583
+ const lastSeen = Number(tab && tab.lastSeen);
584
+ if (!Number.isFinite(lastSeen) || now - lastSeen > DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS) delete tabs[id];
585
+ }
586
+ }
587
+
588
+ function _recordDashboardBrowserPresence(tabId, { closed = false, url = '', visibility = '' } = {}) {
589
+ const id = _normalizeDashboardTabId(tabId);
590
+ if (!id) return false;
591
+ const now = Date.now();
592
+ mutateJsonFileLocked(DASHBOARD_BROWSER_PRESENCE_PATH, (state) => {
593
+ if (!state || typeof state !== 'object' || Array.isArray(state)) state = {};
594
+ if (!state.tabs || typeof state.tabs !== 'object' || Array.isArray(state.tabs)) state.tabs = {};
595
+ _pruneDashboardBrowserTabs(state.tabs, now);
596
+ if (closed) {
597
+ delete state.tabs[id];
598
+ } else {
599
+ state.tabs[id] = {
600
+ id,
601
+ lastSeen: now,
602
+ lastSeenAt: new Date(now).toISOString(),
603
+ url: typeof url === 'string' ? url.slice(0, 512) : '',
604
+ visibility: typeof visibility === 'string' ? visibility.slice(0, 32) : '',
605
+ };
606
+ }
607
+ state.updatedAt = new Date(now).toISOString();
608
+ return state;
609
+ }, { defaultValue: { tabs: {} } });
610
+ return true;
611
+ }
612
+
613
+ function _recordDashboardBrowserPresenceFromRequest(req) {
614
+ const tabId = req && req.headers && req.headers['x-minions-dashboard-tab'];
615
+ if (!tabId) return;
616
+ try {
617
+ _recordDashboardBrowserPresence(tabId, {
618
+ url: req.headers['x-minions-dashboard-url'] || '',
619
+ visibility: req.headers['x-minions-dashboard-visibility'] || '',
620
+ });
621
+ } catch (e) {
622
+ shared.log('warn', `Dashboard browser presence update failed: ${e.message}`);
623
+ }
624
+ }
625
+
626
+ function _getDashboardBrowserPresence(now = Date.now()) {
627
+ const state = safeJsonObj(DASHBOARD_BROWSER_PRESENCE_PATH);
628
+ const tabs = state.tabs && typeof state.tabs === 'object' && !Array.isArray(state.tabs) ? state.tabs : {};
629
+ const activeTabs = Object.values(tabs).filter(tab => {
630
+ const lastSeen = Number(tab && tab.lastSeen);
631
+ return Number.isFinite(lastSeen) && now - lastSeen <= DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS;
632
+ });
633
+ return { active: activeTabs.length > 0, activeTabs: activeTabs.length, maxAgeMs: DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS };
634
+ }
635
+
574
636
  // Hot-reload: watch dashboard/ directory for changes, rebuild, and push reload to browsers
575
637
  const _hotReloadClients = new Set();
576
638
 
@@ -989,12 +1051,7 @@ setInterval(() => {
989
1051
 
990
1052
  // CC chat sessions do NOT auto-expire. A tab is removed only via explicit user
991
1053
  // deletion (DELETE /api/cc-sessions/:id, wired from ccCloseTab). Doc-chat
992
- // sessions keep their own TTL (DOC_SESSION_TTL_MS) that's a separate store.
993
- //
994
- // CC_SESSION_MAX_TURNS is reused by the doc-chat session pruner to cap
995
- // per-session turn growth there; CC chat sessions are not capped because
996
- // users are expected to keep long-running tabs alive indefinitely.
997
- const CC_SESSION_MAX_TURNS = shared.ENGINE_DEFAULTS.ccMaxTurns;
1054
+ // sessions follow the same policysee DOC_SESSIONS section below.
998
1055
  let ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
999
1056
  const ccInFlightTabs = new Map(); // tabId → timestamp — per-tab in-flight tracking for parallel CC requests
1000
1057
  const ccInFlightAborts = new Map(); // tabId → abortFn — lets a new request kill the stale LLM
@@ -2177,11 +2234,14 @@ async function executeDocChatActions(actions) {
2177
2234
 
2178
2235
  // ── Shared LLM call core — used by CC panel and doc modals ──────────────────
2179
2236
 
2180
- // Session store for doc modals — keyed by filePath or title, persisted to disk
2237
+ // Session store for doc modals — keyed by filePath or title, persisted to disk.
2238
+ // Doc-chat sessions do NOT auto-expire (parity with CC tabs). The only
2239
+ // invalidation paths are prompt-hash mismatch (correctness — a stale system
2240
+ // prompt would carry the old persona/rules into resume turns) and the
2241
+ // LRU cap below (storage hygiene). Users can resume a doc-chat any time.
2181
2242
  const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
2182
2243
  const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
2183
2244
  const CC_SESSION_PATH = path.join(ENGINE_DIR, 'cc-session.json');
2184
- const DOC_SESSION_TTL_MS = shared.ENGINE_DEFAULTS.docSessionTtlMs;
2185
2245
  const DOC_SESSION_MAX_ENTRIES = shared.ENGINE_DEFAULTS.docSessionMaxEntries;
2186
2246
  const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
2187
2247
 
@@ -2193,9 +2253,7 @@ function _docSessionLastActiveMs(session) {
2193
2253
  function pruneDocSessions() {
2194
2254
  let changed = false;
2195
2255
  for (const [key, s] of docSessions.entries()) {
2196
- if (!s || (s.turnCount || 0) >= CC_SESSION_MAX_TURNS ||
2197
- _sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS) ||
2198
- s._promptHash !== _docChatPromptHash) {
2256
+ if (!s || s._promptHash !== _docChatPromptHash) {
2199
2257
  docSessions.delete(key);
2200
2258
  changed = true;
2201
2259
  }
@@ -2212,13 +2270,12 @@ function pruneDocSessions() {
2212
2270
  return changed;
2213
2271
  }
2214
2272
 
2215
- // Load persisted doc sessions on startup
2273
+ // Load persisted doc sessions on startup — no TTL/turn filtering, sessions
2274
+ // are non-expiring. Stale prompt-hash entries are dropped by pruneDocSessions.
2216
2275
  try {
2217
2276
  const saved = safeJson(DOC_SESSIONS_PATH);
2218
2277
  if (saved && typeof saved === 'object') {
2219
2278
  for (const [key, s] of Object.entries(saved)) {
2220
- if (s.turnCount >= CC_SESSION_MAX_TURNS) continue;
2221
- if (_sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS)) continue;
2222
2279
  docSessions.set(key, s);
2223
2280
  }
2224
2281
  pruneDocSessions();
@@ -2233,9 +2290,11 @@ function persistDocSessions() {
2233
2290
  mutateJsonFileLocked(DOC_SESSIONS_PATH, () => obj, { defaultValue: {} });
2234
2291
  }
2235
2292
 
2293
+ // Hourly hygiene sweep — drops prompt-hash mismatches and trims the LRU cap.
2294
+ // No TTL involved; sessions live until explicitly evicted by these two policies.
2236
2295
  const _docSessionPruneTimer = setInterval(() => {
2237
2296
  if (pruneDocSessions()) persistDocSessions();
2238
- }, Math.min(DOC_SESSION_TTL_MS, 60 * 60 * 1000));
2297
+ }, 60 * 60 * 1000);
2239
2298
  if (_docSessionPruneTimer.unref) _docSessionPruneTimer.unref();
2240
2299
 
2241
2300
  // Debounced variant — coalesces rapid writes (e.g. back-to-back doc-chat turns)
@@ -2270,16 +2329,6 @@ function resolveSession(store, key) {
2270
2329
  persistDocSessions();
2271
2330
  return null;
2272
2331
  }
2273
- if (s.turnCount >= CC_SESSION_MAX_TURNS) {
2274
- docSessions.delete(key);
2275
- persistDocSessions();
2276
- return null;
2277
- }
2278
- if (_sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS)) {
2279
- docSessions.delete(key);
2280
- persistDocSessions();
2281
- return null;
2282
- }
2283
2332
  return s;
2284
2333
  }
2285
2334
 
@@ -6348,9 +6397,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6348
6397
  const configPath = path.join(MINIONS_DIR, 'config.json');
6349
6398
  const config = safeJson(configPath) || {};
6350
6399
  if (!config.engine) config.engine = {};
6351
- if (!config.claude) config.claude = {};
6352
6400
  if (!config.agents) config.agents = {};
6353
- delete config.claude.permissionMode;
6401
+ shared.pruneDefaultClaudeConfig(config);
6354
6402
 
6355
6403
  const _clamped = [];
6356
6404
  const _engineModelDiscovery = require('./engine/model-discovery');
@@ -6518,9 +6566,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6518
6566
  }
6519
6567
 
6520
6568
  if (body.claude) {
6569
+ if (!config.claude) config.claude = {};
6521
6570
  for (const key of ['allowedTools', 'outputFormat']) {
6522
6571
  if (body.claude[key] !== undefined) config.claude[key] = String(body.claude[key]);
6523
6572
  }
6573
+ shared.pruneDefaultClaudeConfig(config);
6524
6574
  }
6525
6575
 
6526
6576
  if (body.agents) {
@@ -6647,6 +6697,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6647
6697
  }
6648
6698
  }
6649
6699
 
6700
+ shared.pruneDefaultClaudeConfig(config);
6650
6701
  safeWrite(configPath, config);
6651
6702
  // Refresh in-memory CONFIG so subsequent reads see the update
6652
6703
  reloadConfig();
@@ -6672,7 +6723,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6672
6723
  try {
6673
6724
  const config = queries.getConfig();
6674
6725
  config.engine = { ...shared.ENGINE_DEFAULTS };
6675
- config.claude = { ...shared.DEFAULT_CLAUDE };
6726
+ delete config.claude;
6676
6727
  config.agents = { ...shared.DEFAULT_AGENTS };
6677
6728
  safeWrite(path.join(MINIONS_DIR, 'config.json'), config);
6678
6729
  reloadConfig();
@@ -6737,6 +6788,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6737
6788
 
6738
6789
  async function handleStatus(req, res) {
6739
6790
  try {
6791
+ _recordDashboardBrowserPresenceFromRequest(req);
6740
6792
  // Use pre-serialized JSON and pre-computed gzip buffer — zero per-request compression
6741
6793
  const json = getStatusJson();
6742
6794
  res.setHeader('Content-Type', 'application/json');
@@ -6876,6 +6928,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6876
6928
 
6877
6929
  // Status & health
6878
6930
  { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
6931
+ { method: 'GET', path: '/api/browser-presence', desc: 'Whether a dashboard browser tab was recently active', handler: (req, res) => {
6932
+ return jsonReply(res, 200, _getDashboardBrowserPresence(), req);
6933
+ }},
6934
+ { method: 'POST', path: '/api/browser-presence', desc: 'Record dashboard browser tab heartbeat or close', params: 'tabId, closed?, url?, visibility?', handler: async (req, res) => {
6935
+ const body = await readBody(req);
6936
+ const tabId = _normalizeDashboardTabId(body.tabId);
6937
+ if (!tabId) return jsonReply(res, 400, { error: 'valid tabId required' }, req);
6938
+ _recordDashboardBrowserPresence(tabId, {
6939
+ closed: !!body.closed,
6940
+ url: body.url || '',
6941
+ visibility: body.visibility || '',
6942
+ });
6943
+ return jsonReply(res, 200, { ok: true }, req);
6944
+ }},
6879
6945
  { method: 'GET', path: '/api/status-stream', desc: 'SSE stream of real-time status updates', handler: (req, res) => {
6880
6946
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
6881
6947
  res.write('data: ' + getStatusJson() + '\n\n');
@@ -7517,14 +7583,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7517
7583
  if (route.path === '/api/skill') {
7518
7584
  if (!req.url.startsWith('/api/skill?') && req.url !== '/api/skill') continue;
7519
7585
  const _result = await route.handler(req, res, {});
7520
- if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream')) {
7586
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7521
7587
  console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7522
7588
  }
7523
7589
  return _result;
7524
7590
  }
7525
7591
  if (pathname !== route.path) continue;
7526
7592
  const _result = await route.handler(req, res, {});
7527
- if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream')) {
7593
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7528
7594
  console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7529
7595
  }
7530
7596
  return _result;
@@ -7532,7 +7598,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7532
7598
  const m = pathname.match(route.path);
7533
7599
  if (m) {
7534
7600
  const _result = await route.handler(req, res, m);
7535
- if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream')) {
7601
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
7536
7602
  console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
7537
7603
  }
7538
7604
  return _result;
@@ -7654,9 +7720,8 @@ if (require.main === module) {
7654
7720
  console.log(`\n Auto-refreshes every 4s. Ctrl+C to stop.\n`);
7655
7721
 
7656
7722
  // Auto-open the browser unless suppressed. `minions restart` and the
7657
- // upgrade path set MINIONS_NO_AUTO_OPEN=1 when the previous dashboard was
7658
- // already listening the existing tab will SSE-reconnect, so a new tab
7659
- // would just be a duplicate.
7723
+ // upgrade path set MINIONS_NO_AUTO_OPEN=1 only when a browser tab was
7724
+ // recently polling the old dashboard, so a new tab would just be a duplicate.
7660
7725
  if (!process.env.MINIONS_NO_AUTO_OPEN) {
7661
7726
  const { exec } = require('child_process');
7662
7727
  try {
package/engine/cleanup.js CHANGED
@@ -48,6 +48,105 @@ function worktreeMatchesBranch(dirLower, branch, actualBranch = '') {
48
48
  return worktreeBranchMatches(actualBranch, branch) || worktreeDirMatchesBranch(dirLower, branch);
49
49
  }
50
50
 
51
+ function normalizeLocalBranchName(branch) {
52
+ return String(branch || '').trim().replace(/^refs\/heads\//i, '');
53
+ }
54
+
55
+ function isSafeLocalBranchName(branch) {
56
+ if (!branch || branch !== sanitizeBranch(branch)) return false;
57
+ if (branch.startsWith('-') || branch.includes('..') || branch.includes('@{')) return false;
58
+ if (branch.endsWith('/') || branch.endsWith('.lock')) return false;
59
+ return branch.split('/').every(part => part && part !== '.' && part !== '..' && !part.endsWith('.lock'));
60
+ }
61
+
62
+ function isProtectedLocalBranch(branch, project = {}) {
63
+ const protectedBranches = new Set(['main', 'master', 'trunk', 'develop', 'development', 'head']);
64
+ const configuredMain = normalizeLocalBranchName(project.mainBranch);
65
+ if (configuredMain) protectedBranches.add(configuredMain.toLowerCase());
66
+ return protectedBranches.has(branch.toLowerCase());
67
+ }
68
+
69
+ function localBranchWorktreeInUse(root, branch) {
70
+ try {
71
+ const out = String(shared.execSilent('git worktree list --porcelain', {
72
+ cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
73
+ }) || '');
74
+ return out.split(/\r?\n/).some(line => line.trim() === `branch refs/heads/${branch}`);
75
+ } catch {
76
+ return true;
77
+ }
78
+ }
79
+
80
+ function cleanupMergedPrLocalBranch(root, project, pr) {
81
+ const branch = normalizeLocalBranchName(pr?.branch);
82
+ const result = { deleted: false, forced: false, skipped: null };
83
+ if (pr?.status !== shared.PR_STATUS.MERGED) { result.skipped = 'not-merged'; return result; }
84
+ if (!root || !branch) { result.skipped = 'missing-branch'; return result; }
85
+ if (!isSafeLocalBranchName(branch)) { result.skipped = 'unsafe-branch-name'; return result; }
86
+ if (isProtectedLocalBranch(branch, project)) { result.skipped = 'protected-branch'; return result; }
87
+
88
+ try {
89
+ const current = String(shared.execSilent('git branch --show-current', {
90
+ cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
91
+ }) || '').trim();
92
+ if (current === branch) { result.skipped = 'current-branch'; return result; }
93
+ } catch {
94
+ result.skipped = 'current-branch-unknown';
95
+ return result;
96
+ }
97
+
98
+ if (localBranchWorktreeInUse(root, branch)) { result.skipped = 'branch-in-worktree'; return result; }
99
+
100
+ let localHead = '';
101
+ try {
102
+ localHead = String(shared.execSilent(`git rev-parse --verify "refs/heads/${branch}"`, {
103
+ cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
104
+ }) || '').trim();
105
+ } catch {
106
+ result.skipped = 'missing-local-branch';
107
+ return result;
108
+ }
109
+
110
+ try {
111
+ shared.execSilent(`git branch -d -- "${branch}"`, {
112
+ cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 15000, windowsHide: true,
113
+ });
114
+ log('info', `Post-merge cleanup: deleted local branch ${branch}`);
115
+ return { deleted: true, forced: false, skipped: null };
116
+ } catch (deleteErr) {
117
+ const localHeadLower = localHead.toLowerCase();
118
+ // Only use -D when the local tip still matches the merged PR head (or its remote-tracking ref);
119
+ // otherwise a reused local branch could lose unrelated work after the PR merged.
120
+ const proofHeads = [pr.headSha, pr._adoSourceCommit, pr.sourceCommit]
121
+ .map(v => String(v || '').trim().toLowerCase())
122
+ .filter(Boolean);
123
+ let safeToForce = proofHeads.includes(localHeadLower);
124
+ if (!safeToForce) {
125
+ try {
126
+ const remoteHead = String(shared.execSilent(`git rev-parse --verify "refs/remotes/origin/${branch}"`, {
127
+ cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
128
+ }) || '').trim().toLowerCase();
129
+ safeToForce = !!remoteHead && remoteHead === localHeadLower;
130
+ } catch { /* no matching remote-tracking branch */ }
131
+ }
132
+ if (!safeToForce) {
133
+ result.skipped = 'unproven-force-delete';
134
+ return result;
135
+ }
136
+ try {
137
+ shared.execSilent(`git branch -D -- "${branch}"`, {
138
+ cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 15000, windowsHide: true,
139
+ });
140
+ log('info', `Post-merge cleanup: force-deleted local branch ${branch} after merged PR confirmation`);
141
+ return { deleted: true, forced: true, skipped: null };
142
+ } catch (forceErr) {
143
+ log('warn', `Post-merge cleanup: failed to delete local branch ${branch}: ${forceErr.message || deleteErr.message}`);
144
+ result.skipped = 'delete-failed';
145
+ return result;
146
+ }
147
+ }
148
+ }
149
+
51
150
  /**
52
151
  * Sweep leaked test-fixture meetings from a `meetings/` directory.
53
152
  *
@@ -342,9 +441,11 @@ async function runCleanup(config, verbose = false) {
342
441
  // Check if this worktree's branch is merged/abandoned
343
442
  // Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
344
443
  const dirLower = dir.toLowerCase();
444
+ let matchedMergedBranch = '';
345
445
  for (const branch of mergedBranches) {
346
446
  if (worktreeMatchesBranch(dirLower, branch, actualBranch)) {
347
447
  shouldClean = true;
448
+ matchedMergedBranch = branch;
348
449
  break;
349
450
  }
350
451
  }
@@ -392,7 +493,7 @@ async function runCleanup(config, verbose = false) {
392
493
  } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
393
494
  }
394
495
 
395
- wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch });
496
+ wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch, matchedMergedBranch });
396
497
  }
397
498
 
398
499
  // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
@@ -412,10 +513,14 @@ async function runCleanup(config, verbose = false) {
412
513
  // the initial status check and the actual deletion (Bug #15: TOCTOU race)
413
514
  const freshPrs = safeJson(projectPrPath(project)) || [];
414
515
  const freshMergedBranches = new Set();
516
+ const freshMergedPrByBranch = new Map();
415
517
  for (const pr of freshPrs) {
416
518
  if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED || pr.status === shared.PLAN_STATUS.COMPLETED) {
417
519
  if (pr.branch) freshMergedBranches.add(pr.branch);
418
520
  }
521
+ if (pr.status === shared.PR_STATUS.MERGED && pr.branch) {
522
+ freshMergedPrByBranch.set(sanitizeBranch(normalizeLocalBranchName(pr.branch)).toLowerCase(), pr);
523
+ }
419
524
  }
420
525
 
421
526
  for (const entry of wtEntries) {
@@ -446,6 +551,10 @@ async function runCleanup(config, verbose = false) {
446
551
  _killProcessInWorktree(entry.dir, activeProcesses, activeDispatchIds);
447
552
  if (shared.removeWorktree(entry.wtPath, root, worktreeRoot)) {
448
553
  cleaned.worktrees++;
554
+ const mergedPr = entry.matchedMergedBranch
555
+ ? freshMergedPrByBranch.get(sanitizeBranch(normalizeLocalBranchName(entry.matchedMergedBranch)).toLowerCase())
556
+ : null;
557
+ if (mergedPr) cleanupMergedPrLocalBranch(root, project, mergedPr);
449
558
  if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
450
559
  } else {
451
560
  if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}`);
@@ -935,4 +1044,5 @@ module.exports = {
935
1044
  worktreeDirMatchesBranch, // exported for testing
936
1045
  worktreeMatchesBranch, // exported for testing
937
1046
  getWorktreeBranch, // exported for lifecycle cleanup
1047
+ cleanupMergedPrLocalBranch, // exported for lifecycle cleanup and testing
938
1048
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-07T15:45:25.869Z"
4
+ "cachedAt": "2026-05-07T16:58:12.762Z"
5
5
  }
@@ -7,14 +7,14 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
- const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
10
+ const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const { resolveRuntime } = require('./runtimes');
15
15
  const queries = require('./queries');
16
16
  const { isBranchActive } = require('./cooldown');
17
- const { worktreeMatchesBranch, getWorktreeBranch } = require('./cleanup');
17
+ const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
18
18
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
19
19
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
20
20
 
@@ -1838,6 +1838,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
1838
1838
  if (pr.branch && project) {
1839
1839
  const root = path.resolve(project.localPath);
1840
1840
  const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1841
+ let removedBranchWorktree = false;
1841
1842
  // Find worktrees matching this branch; compact Windows dirs require branch metadata.
1842
1843
  try {
1843
1844
  const dirs = require('fs').readdirSync(wtRoot);
@@ -1847,11 +1848,16 @@ async function handlePostMerge(pr, project, config, newStatus) {
1847
1848
  if (worktreeMatchesBranch(dirLower, pr.branch, getWorktreeBranch(wtPath)) || dir === pr.branch || dir === `bt-${prNum}`) {
1848
1849
  try {
1849
1850
  if (!require('fs').statSync(wtPath).isDirectory()) continue;
1850
- execSilent(`git worktree remove "${wtPath}" --force`, { cwd: root, stdio: 'pipe', timeout: 15000 });
1851
- log('info', `Post-merge cleanup: removed worktree ${dir}`);
1851
+ if (shared.removeWorktree(wtPath, root, wtRoot)) {
1852
+ removedBranchWorktree = true;
1853
+ log('info', `Post-merge cleanup: removed worktree ${dir}`);
1854
+ }
1852
1855
  } catch (err) { log('warn', `Failed to remove worktree ${dir}: ${err.message}`); }
1853
1856
  }
1854
1857
  }
1858
+ if (removedBranchWorktree && newStatus === PR_STATUS.MERGED) {
1859
+ cleanupMergedPrLocalBranch(root, project, pr);
1860
+ }
1855
1861
  } catch (err) { log('warn', `Post-merge worktree cleanup: ${err.message}`); }
1856
1862
  }
1857
1863
 
package/engine/shared.js CHANGED
@@ -1029,9 +1029,8 @@ const ENGINE_DEFAULTS = {
1029
1029
  mainBranchCacheMaxEntries: 100, // bound repo/branch detection cache in long-lived dashboard/engine processes
1030
1030
  removeWorktreeFailureTtlMs: 24 * 60 * 60 * 1000, // stale failed paths are forgotten after a day
1031
1031
  removeWorktreeFailureMaxEntries: 1000, // bound failed-worktree retry suppression cache
1032
- ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
1033
- docSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d longer-lived doc sessions, still bounded
1034
- docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity
1032
+ ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
1033
+ docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
1035
1034
  ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
1036
1035
  metricsFlushIntervalMs: 10000, // batch trackEngineUsage writes to metrics.json — flushed every 10s instead of per-call to cut lock contention and dashboard mtime churn
1037
1036
  maxLlmRawBytes: 256 * 1024, // keep only a bounded stdout tail from direct Claude calls
@@ -1244,7 +1243,7 @@ function _resetLegacyCcModelMigrationFlag() {
1244
1243
  * Warnings emitted:
1245
1244
  * - Unknown CLI: any `cli` value (per-agent, ccCli, defaultCli) not in
1246
1245
  * `registeredRuntimes`. Each unknown value produces one entry.
1247
- * - Deprecated `config.claude.*` fields: presence of any field in
1246
+ * - Deprecated `config.claude.*` overrides: non-default fields listed in
1248
1247
  * `ENGINE_DEFAULTS._deprecatedConfigClaudeFields` under `config.claude`.
1249
1248
  * - Bare-mode misconfig: `engine.claudeBareMode === true` paired with
1250
1249
  * CC running on the Claude runtime (resolved via `resolveCcCli`) and no
@@ -1283,11 +1282,15 @@ function runtimeConfigWarnings(config, registeredRuntimes) {
1283
1282
  if (agent && typeof agent === 'object') checkCli(`agents.${agentId}.cli`, agent.cli);
1284
1283
  }
1285
1284
 
1286
- // 2. Deprecated `config.claude.*` fields.
1285
+ // 2. Deprecated `config.claude.*` overrides. Generated defaults from older
1286
+ // init versions are ignored here and pruned the next time config is saved.
1287
1287
  const claude = config.claude;
1288
1288
  if (claude && typeof claude === 'object') {
1289
1289
  const deprecatedKeys = ENGINE_DEFAULTS._deprecatedConfigClaudeFields || [];
1290
- const present = deprecatedKeys.filter(k => Object.prototype.hasOwnProperty.call(claude, k));
1290
+ const present = deprecatedKeys.filter(k => {
1291
+ if (!Object.prototype.hasOwnProperty.call(claude, k)) return false;
1292
+ return !(Object.prototype.hasOwnProperty.call(DEFAULT_CLAUDE, k) && claude[k] === DEFAULT_CLAUDE[k]);
1293
+ });
1291
1294
  if (present.length > 0) {
1292
1295
  warnings.push({
1293
1296
  id: 'deprecated-config-claude',
@@ -1573,6 +1576,37 @@ const DEFAULT_CLAUDE = {
1573
1576
  allowedTools: 'Edit,Write,Read,Bash,Glob,Grep,Agent,WebFetch,WebSearch',
1574
1577
  };
1575
1578
 
1579
+ function pruneDefaultClaudeConfig(config) {
1580
+ if (!config || typeof config !== 'object') return false;
1581
+ const claude = config.claude;
1582
+ if (claude === undefined || claude === null) return false;
1583
+ if (typeof claude !== 'object' || Array.isArray(claude)) {
1584
+ delete config.claude;
1585
+ return true;
1586
+ }
1587
+
1588
+ let changed = false;
1589
+ const removeKey = (key) => {
1590
+ if (Object.prototype.hasOwnProperty.call(claude, key)) {
1591
+ delete claude[key];
1592
+ changed = true;
1593
+ }
1594
+ };
1595
+
1596
+ removeKey('permissionMode');
1597
+ for (const key of ['binary', 'outputFormat', 'allowedTools']) {
1598
+ if (Object.prototype.hasOwnProperty.call(claude, key) && claude[key] === DEFAULT_CLAUDE[key]) {
1599
+ removeKey(key);
1600
+ }
1601
+ }
1602
+
1603
+ if (Object.keys(claude).length === 0) {
1604
+ delete config.claude;
1605
+ changed = true;
1606
+ }
1607
+ return changed;
1608
+ }
1609
+
1576
1610
  // ── Project Helpers ──────────────────────────────────────────────────────────
1577
1611
 
1578
1612
  function getProjects(config) {
@@ -3068,6 +3102,7 @@ module.exports = {
3068
3102
  DEFAULT_AGENT_METRICS,
3069
3103
  DEFAULT_AGENTS,
3070
3104
  DEFAULT_CLAUDE,
3105
+ pruneDefaultClaudeConfig,
3071
3106
  getProjects,
3072
3107
  projectRoot,
3073
3108
  projectStateDir,
package/minions.js CHANGED
@@ -17,7 +17,7 @@ const path = require('path');
17
17
  const readline = require('readline');
18
18
  const { execSync } = require('child_process');
19
19
  const shared = require('./engine/shared');
20
- const { ENGINE_DEFAULTS, DEFAULT_AGENTS, DEFAULT_CLAUDE } = shared;
20
+ const { ENGINE_DEFAULTS, DEFAULT_AGENTS } = shared;
21
21
  const projectDiscovery = require('./engine/project-discovery');
22
22
 
23
23
  const MINIONS_HOME = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : __dirname;
@@ -385,11 +385,7 @@ async function initMinions({ skipScan = false, scanRoot, scanDepth } = {}) {
385
385
  if (k === 'defaultCli') continue;
386
386
  if (config.engine[k] === undefined) config.engine[k] = v;
387
387
  }
388
- if (!config.claude) config.claude = {};
389
- delete config.claude.permissionMode;
390
- for (const [k, v] of Object.entries(DEFAULT_CLAUDE)) {
391
- if (config.claude[k] === undefined) config.claude[k] = v;
392
- }
388
+ shared.pruneDefaultClaudeConfig(config);
393
389
  if (!config.agents || Object.keys(config.agents).length === 0) {
394
390
  config.agents = { ...DEFAULT_AGENTS };
395
391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1769",
3
+ "version": "0.1.1771",
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"