@yemi33/minions 0.1.1776 → 0.1.1778

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1778 (2026-05-07)
4
+
5
+ ### Features
6
+ - harden dashboard state mutations (#2175)
7
+
8
+ ## 0.1.1777 (2026-05-07)
9
+
10
+ ### Fixes
11
+ - tighten worktreeRoot validation everywhere worktrees are created
12
+
3
13
  ## 0.1.1776 (2026-05-07)
4
14
 
5
15
  ### Fixes
package/dashboard.js CHANGED
@@ -34,7 +34,7 @@ const projectDiscovery = require('./engine/project-discovery');
34
34
  const features = require('./engine/features');
35
35
  const os = require('os');
36
36
 
37
- const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
37
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
38
38
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
39
39
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
40
40
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
@@ -61,6 +61,10 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
61
61
  const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
62
62
  let CONFIG = queries.getConfig();
63
63
  let PROJECTS = _getProjects(CONFIG);
64
+ const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
65
+ const PINNED_PATH = path.join(MINIONS_DIR, 'pinned.md');
66
+ const PINNED_DEFAULT_CONTENT = '# Pinned Context\n\nCritical notes visible to all agents.';
67
+ const KB_PINS_PATH = shared.PINNED_ITEMS_PATH;
64
68
  const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
65
69
  const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
66
70
 
@@ -93,6 +97,89 @@ function reloadConfig() {
93
97
  }
94
98
  ensureConfiguredProjectStateFiles();
95
99
 
100
+ function mutateDashboardConfig(mutator) {
101
+ return mutateJsonFileLocked(CONFIG_PATH, (config) => {
102
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
103
+ const next = mutator(config);
104
+ return next === undefined ? config : next;
105
+ }, { defaultValue: { projects: [], agents: {}, engine: {} }, skipWriteIfUnchanged: true });
106
+ }
107
+
108
+ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
109
+ if (!current || typeof current !== 'object' || Array.isArray(current)) current = {};
110
+ if (body.engine) {
111
+ if (!current.engine || typeof current.engine !== 'object' || Array.isArray(current.engine)) current.engine = {};
112
+ const enginePatch = patch.engine || {};
113
+ for (const key of enginePatch.delete || []) delete current.engine[key];
114
+ for (const [key, value] of Object.entries(enginePatch.set || {})) current.engine[key] = value;
115
+ }
116
+ if (body.claude) {
117
+ if (candidate.claude) current.claude = candidate.claude;
118
+ else delete current.claude;
119
+ }
120
+ if (body.agents) {
121
+ if (!current.agents || typeof current.agents !== 'object' || Array.isArray(current.agents)) current.agents = {};
122
+ for (const id of Object.keys(body.agents)) {
123
+ if (candidate.agents && candidate.agents[id]) current.agents[id] = candidate.agents[id];
124
+ }
125
+ }
126
+ if (body.teams) {
127
+ if (candidate.teams) current.teams = candidate.teams;
128
+ else delete current.teams;
129
+ }
130
+ if (body.projects && Array.isArray(body.projects)) {
131
+ if (!Array.isArray(current.projects)) current.projects = [];
132
+ for (const update of body.projects) {
133
+ const candidateProject = (candidate.projects || []).find(p => p.name === update.name);
134
+ const currentProject = current.projects.find(p => p.name === update.name);
135
+ if (!candidateProject || !currentProject) continue;
136
+ currentProject.workSources = candidateProject.workSources;
137
+ }
138
+ }
139
+ shared.pruneDefaultClaudeConfig(current);
140
+ return current;
141
+ }
142
+
143
+ function addPinnedEntryLocked({ title, content, level }, now = new Date()) {
144
+ const levelTag = level === 'critical' ? '🔴 ' : level === 'warning' ? '🟡 ' : '';
145
+ const entry = '\n\n### ' + levelTag + title + '\n\n' + content + '\n\n*Pinned by human on ' + now.toISOString().slice(0, 10) + '*';
146
+ return mutateTextFileLocked(PINNED_PATH, existing => (existing || PINNED_DEFAULT_CONTENT) + entry, { defaultValue: PINNED_DEFAULT_CONTENT });
147
+ }
148
+
149
+ function removePinnedEntryLocked(title) {
150
+ let missing = false;
151
+ mutateTextFileLocked(PINNED_PATH, content => {
152
+ if (!content) {
153
+ missing = true;
154
+ return content;
155
+ }
156
+ const regex = new RegExp('\\n\\n###\\s*(?:🔴\\s*|🟡\\s*)?' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\n[\\s\\S]*?(?=\\n\\n###|$)', 'i');
157
+ return content.replace(regex, '');
158
+ }, { defaultValue: '', skipWriteIfUnchanged: true });
159
+ return !missing;
160
+ }
161
+
162
+ function setKbPinsLocked(pins) {
163
+ return mutateJsonFileLocked(KB_PINS_PATH, () => pins, { defaultValue: [], skipWriteIfUnchanged: true });
164
+ }
165
+
166
+ function toggleKbPinLocked(key) {
167
+ let pinned = false;
168
+ mutateJsonFileLocked(KB_PINS_PATH, pins => {
169
+ if (!Array.isArray(pins)) pins = [];
170
+ const idx = pins.indexOf(key);
171
+ if (idx >= 0) {
172
+ pins.splice(idx, 1);
173
+ pinned = false;
174
+ } else {
175
+ pins.unshift(key);
176
+ pinned = true;
177
+ }
178
+ return pins;
179
+ }, { defaultValue: [], skipWriteIfUnchanged: true });
180
+ return pinned;
181
+ }
182
+
96
183
  function getWorkItemIdFromPrLinkContext(context, workItemId) {
97
184
  if (typeof workItemId === 'string' && workItemId.trim()) return workItemId.trim();
98
185
  if (!context) return null;
@@ -995,7 +1082,7 @@ function getStatus() {
995
1082
  });
996
1083
  })(),
997
1084
  pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
998
- pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
1085
+ pinned: (() => { try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; } })(),
999
1086
  projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
1000
1087
  autoMode: {
1001
1088
  approvePlans: !!CONFIG.engine?.autoApprovePlans,
@@ -5734,13 +5821,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5734
5821
  });
5735
5822
  }
5736
5823
 
5737
- const configPath = path.join(MINIONS_DIR, 'config.json');
5738
- const config = safeJsonObj(configPath);
5739
- if (!config) return jsonReply(res, 500, { error: 'failed to read config' });
5740
- if (!config.projects) config.projects = [];
5741
-
5742
- // Check if already linked
5743
- if (config.projects.find(p => path.resolve(p.localPath) === target)) {
5824
+ // Check if already linked under the config lock so concurrent dashboard
5825
+ // adds cannot both pass the preflight check and then clobber config.json.
5826
+ let alreadyLinked = false;
5827
+ mutateDashboardConfig(config => {
5828
+ if (!Array.isArray(config.projects)) config.projects = [];
5829
+ alreadyLinked = config.projects.some(p => path.resolve(p.localPath) === target);
5830
+ return config;
5831
+ });
5832
+ if (alreadyLinked) {
5744
5833
  return jsonReply(res, 400, { error: 'Project already linked at ' + target });
5745
5834
  }
5746
5835
 
@@ -5779,8 +5868,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5779
5868
  // .minions state without leaving repo-local state files behind.
5780
5869
  shared.ensureProjectStateFiles(project, { migrateLegacy: true, removeLegacy: true });
5781
5870
 
5782
- config.projects.push(project);
5783
- safeWrite(configPath, config);
5871
+ let duplicate = false;
5872
+ mutateDashboardConfig(config => {
5873
+ if (!Array.isArray(config.projects)) config.projects = [];
5874
+ if (config.projects.some(p => path.resolve(p.localPath) === target)) {
5875
+ duplicate = true;
5876
+ return config;
5877
+ }
5878
+ config.projects.push(project);
5879
+ return config;
5880
+ });
5881
+ if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
5784
5882
  reloadConfig(); // Update in-memory project list immediately
5785
5883
  invalidateStatusCache();
5786
5884
 
@@ -6358,24 +6456,25 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6358
6456
  if (!id) id = 'schedule';
6359
6457
  }
6360
6458
 
6459
+ let sched;
6460
+ mutateDashboardConfig(config => {
6461
+ if (!Array.isArray(config.schedules)) config.schedules = [];
6462
+ // If auto-generated ID collides, append a short numeric suffix
6463
+ let scheduleId = id;
6464
+ if (config.schedules.some(s => s.id === scheduleId)) {
6465
+ let suffix = 2;
6466
+ while (config.schedules.some(s => s.id === `${scheduleId}-${suffix}`)) suffix++;
6467
+ scheduleId = `${scheduleId}-${suffix}`;
6468
+ }
6469
+ sched = { id: scheduleId, cron, title, type: type || 'implement', enabled: enabled !== false };
6470
+ if (project) sched.project = project;
6471
+ if (agent) sched.agent = agent;
6472
+ if (description) sched.description = description;
6473
+ if (priority) sched.priority = priority;
6474
+ config.schedules.push(sched);
6475
+ return config;
6476
+ });
6361
6477
  reloadConfig();
6362
- if (!CONFIG.schedules) CONFIG.schedules = [];
6363
-
6364
- // If auto-generated ID collides, append a short numeric suffix
6365
- if (CONFIG.schedules.some(s => s.id === id)) {
6366
- let suffix = 2;
6367
- while (CONFIG.schedules.some(s => s.id === `${id}-${suffix}`)) suffix++;
6368
- id = `${id}-${suffix}`;
6369
- }
6370
-
6371
- const sched = { id, cron, title, type: type || 'implement', enabled: enabled !== false };
6372
- if (project) sched.project = project;
6373
- if (agent) sched.agent = agent;
6374
- if (description) sched.description = description;
6375
- if (priority) sched.priority = priority;
6376
-
6377
- CONFIG.schedules.push(sched);
6378
- safeWrite(path.join(MINIONS_DIR, 'config.json'), CONFIG);
6379
6478
  invalidateStatusCache();
6380
6479
  return jsonReply(res, 200, { ok: true, schedule: sched });
6381
6480
  }
@@ -6385,21 +6484,28 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6385
6484
  const { id, cron, title, type, project, agent, description, priority, enabled } = body;
6386
6485
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6387
6486
 
6388
- reloadConfig();
6389
- if (!CONFIG.schedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6390
- const sched = CONFIG.schedules.find(s => s.id === id);
6487
+ let missingSchedules = false;
6488
+ let sched = null;
6489
+ mutateDashboardConfig(config => {
6490
+ if (!Array.isArray(config.schedules)) {
6491
+ missingSchedules = true;
6492
+ return config;
6493
+ }
6494
+ sched = config.schedules.find(s => s.id === id);
6495
+ if (!sched) return config;
6496
+ if (cron !== undefined) sched.cron = cron;
6497
+ if (title !== undefined) sched.title = title;
6498
+ if (type !== undefined) sched.type = type;
6499
+ if (project !== undefined) sched.project = project || null;
6500
+ if (agent !== undefined) sched.agent = agent || null;
6501
+ if (description !== undefined) sched.description = description;
6502
+ if (priority !== undefined) sched.priority = priority;
6503
+ if (enabled !== undefined) sched.enabled = enabled;
6504
+ return config;
6505
+ });
6506
+ if (missingSchedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6391
6507
  if (!sched) return jsonReply(res, 404, { error: 'Schedule not found' });
6392
-
6393
- if (cron !== undefined) sched.cron = cron;
6394
- if (title !== undefined) sched.title = title;
6395
- if (type !== undefined) sched.type = type;
6396
- if (project !== undefined) sched.project = project || null;
6397
- if (agent !== undefined) sched.agent = agent || null;
6398
- if (description !== undefined) sched.description = description;
6399
- if (priority !== undefined) sched.priority = priority;
6400
- if (enabled !== undefined) sched.enabled = enabled;
6401
-
6402
- safeWrite(path.join(MINIONS_DIR, 'config.json'), CONFIG);
6508
+ reloadConfig();
6403
6509
  invalidateStatusCache();
6404
6510
  return jsonReply(res, 200, { ok: true, schedule: sched });
6405
6511
  }
@@ -6409,13 +6515,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6409
6515
  const { id } = body;
6410
6516
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6411
6517
 
6518
+ let missingSchedules = false;
6519
+ let deleted = false;
6520
+ mutateDashboardConfig(config => {
6521
+ if (!Array.isArray(config.schedules)) {
6522
+ missingSchedules = true;
6523
+ return config;
6524
+ }
6525
+ const idx = config.schedules.findIndex(s => s.id === id);
6526
+ if (idx < 0) return config;
6527
+ config.schedules.splice(idx, 1);
6528
+ deleted = true;
6529
+ return config;
6530
+ });
6531
+ if (missingSchedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6532
+ if (!deleted) return jsonReply(res, 404, { error: 'Schedule not found' });
6412
6533
  reloadConfig();
6413
- if (!CONFIG.schedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6414
- const idx = CONFIG.schedules.findIndex(s => s.id === id);
6415
- if (idx < 0) return jsonReply(res, 404, { error: 'Schedule not found' });
6416
-
6417
- CONFIG.schedules.splice(idx, 1);
6418
- safeWrite(path.join(MINIONS_DIR, 'config.json'), CONFIG);
6419
6534
  invalidateStatusCache();
6420
6535
  return jsonReply(res, 200, { ok: true });
6421
6536
  }
@@ -6588,8 +6703,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6588
6703
  async function handleSettingsUpdate(req, res) {
6589
6704
  try {
6590
6705
  const body = await readBody(req);
6591
- const configPath = path.join(MINIONS_DIR, 'config.json');
6592
- const config = safeJson(configPath) || {};
6706
+ const config = safeJson(CONFIG_PATH) || {};
6593
6707
  if (!config.engine) config.engine = {};
6594
6708
  if (!config.agents) config.agents = {};
6595
6709
  shared.pruneDefaultClaudeConfig(config);
@@ -6597,6 +6711,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6597
6711
  const _clamped = [];
6598
6712
  const _engineModelDiscovery = require('./engine/model-discovery');
6599
6713
  const _engineRuntimes = require('./engine/runtimes');
6714
+ const _configPatch = { engine: { set: {}, delete: new Set() } };
6715
+ function _setEngineConfig(key, value) {
6716
+ config.engine[key] = value;
6717
+ _configPatch.engine.set[key] = value;
6718
+ _configPatch.engine.delete.delete(key);
6719
+ }
6720
+ function _deleteEngineConfig(key) {
6721
+ delete config.engine[key];
6722
+ delete _configPatch.engine.set[key];
6723
+ _configPatch.engine.delete.add(key);
6724
+ }
6600
6725
  function _resolveModelForRuntime(modelStr, runtimeName) {
6601
6726
  try {
6602
6727
  const adapter = _engineRuntimes.resolveRuntime(runtimeName);
@@ -6630,13 +6755,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6630
6755
  val = Math.max(min, val);
6631
6756
  if (max !== undefined) val = Math.min(max, val);
6632
6757
  if (val !== raw) _clamped.push(`${key}: ${raw} → ${val} (range: ${min}–${max || '∞'})`);
6633
- config.engine[key] = val;
6758
+ _setEngineConfig(key, val);
6634
6759
  }
6635
6760
  }
6636
- delete config.engine.adoPollStatusEvery;
6637
- delete config.engine.adoPollCommentsEvery;
6761
+ _deleteEngineConfig('adoPollStatusEvery');
6762
+ _deleteEngineConfig('adoPollCommentsEvery');
6638
6763
  // String fields
6639
- if (e.worktreeRoot !== undefined) config.engine.worktreeRoot = String(e.worktreeRoot || D.worktreeRoot);
6764
+ if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
6640
6765
 
6641
6766
  // ── Runtime fleet (P-7a5c1f8e) ─────────────────────────────────────
6642
6767
  // Empty string clears the override — the dashboard's "Default (CLI
@@ -6653,13 +6778,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6653
6778
  return _registeredCliNames.length === 0 || _registeredCliNames.includes(String(name));
6654
6779
  };
6655
6780
  if (e.defaultCli !== undefined) {
6656
- if (_isClear(e.defaultCli)) delete config.engine.defaultCli;
6657
- else if (_validCli(e.defaultCli)) config.engine.defaultCli = String(e.defaultCli);
6781
+ if (_isClear(e.defaultCli)) _deleteEngineConfig('defaultCli');
6782
+ else if (_validCli(e.defaultCli)) _setEngineConfig('defaultCli', String(e.defaultCli));
6658
6783
  else _clamped.push(`defaultCli: "${e.defaultCli}" not registered (kept previous value)`);
6659
6784
  }
6660
6785
  if (e.ccCli !== undefined) {
6661
- if (_isClear(e.ccCli)) delete config.engine.ccCli;
6662
- else if (_validCli(e.ccCli)) config.engine.ccCli = String(e.ccCli);
6786
+ if (_isClear(e.ccCli)) _deleteEngineConfig('ccCli');
6787
+ else if (_validCli(e.ccCli)) _setEngineConfig('ccCli', String(e.ccCli));
6663
6788
  else _clamped.push(`ccCli: "${e.ccCli}" not registered (kept previous value)`);
6664
6789
  }
6665
6790
  // Validate fleet-level model assignments against the resolved runtime.
@@ -6695,47 +6820,47 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6695
6820
  return null;
6696
6821
  }
6697
6822
  if (e.defaultModel !== undefined) {
6698
- if (_isClear(e.defaultModel)) delete config.engine.defaultModel;
6823
+ if (_isClear(e.defaultModel)) _deleteEngineConfig('defaultModel');
6699
6824
  else {
6700
6825
  const candidate = String(e.defaultModel);
6701
6826
  const resolvedCli = config.engine.defaultCli || 'claude';
6702
6827
  const rejection = await _validateFleetModel(candidate, resolvedCli);
6703
6828
  if (rejection) _clamped.push(`engine.defaultModel: "${candidate}" ${rejection} — kept previous value`);
6704
- else config.engine.defaultModel = candidate;
6829
+ else _setEngineConfig('defaultModel', candidate);
6705
6830
  }
6706
6831
  }
6707
6832
  if (e.ccModel !== undefined) {
6708
- if (_isClear(e.ccModel)) delete config.engine.ccModel;
6833
+ if (_isClear(e.ccModel)) _deleteEngineConfig('ccModel');
6709
6834
  else {
6710
6835
  const candidate = String(e.ccModel);
6711
6836
  const resolvedCli = config.engine.ccCli || config.engine.defaultCli || 'claude';
6712
6837
  const rejection = await _validateFleetModel(candidate, resolvedCli);
6713
6838
  if (rejection) _clamped.push(`engine.ccModel: "${candidate}" ${rejection} — kept previous value`);
6714
- else config.engine.ccModel = candidate;
6839
+ else _setEngineConfig('ccModel', candidate);
6715
6840
  }
6716
6841
  }
6717
6842
  if (e.claudeFallbackModel !== undefined) {
6718
- if (_isClear(e.claudeFallbackModel)) delete config.engine.claudeFallbackModel;
6719
- else config.engine.claudeFallbackModel = String(e.claudeFallbackModel);
6843
+ if (_isClear(e.claudeFallbackModel)) _deleteEngineConfig('claudeFallbackModel');
6844
+ else _setEngineConfig('claudeFallbackModel', String(e.claudeFallbackModel));
6720
6845
  }
6721
6846
  if (e.copilotStreamMode !== undefined) {
6722
6847
  const valid = ['on', 'off'];
6723
- if (_isClear(e.copilotStreamMode)) delete config.engine.copilotStreamMode;
6724
- else if (valid.includes(e.copilotStreamMode)) config.engine.copilotStreamMode = e.copilotStreamMode;
6848
+ if (_isClear(e.copilotStreamMode)) _deleteEngineConfig('copilotStreamMode');
6849
+ else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
6725
6850
  else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
6726
6851
  }
6727
6852
  // maxBudgetUsd uses ?? semantics — 0 is a valid cap (read-only / dry-run agents).
6728
6853
  if (e.maxBudgetUsd !== undefined) {
6729
- if (_isClear(e.maxBudgetUsd)) delete config.engine.maxBudgetUsd;
6854
+ if (_isClear(e.maxBudgetUsd)) _deleteEngineConfig('maxBudgetUsd');
6730
6855
  else {
6731
6856
  const n = Number(e.maxBudgetUsd);
6732
- if (Number.isFinite(n) && n >= 0) config.engine.maxBudgetUsd = n;
6857
+ if (Number.isFinite(n) && n >= 0) _setEngineConfig('maxBudgetUsd', n);
6733
6858
  else _clamped.push(`maxBudgetUsd: "${e.maxBudgetUsd}" must be ≥ 0 (kept previous value)`);
6734
6859
  }
6735
6860
  }
6736
6861
  if (e.ccEffort !== undefined) {
6737
6862
  const valid = [null, 'low', 'medium', 'high'];
6738
- config.engine.ccEffort = valid.includes(e.ccEffort) ? e.ccEffort : null;
6863
+ _setEngineConfig('ccEffort', valid.includes(e.ccEffort) ? e.ccEffort : null);
6739
6864
  }
6740
6865
  // Per-type max turns
6741
6866
  if (e.maxTurnsByType !== undefined && typeof e.maxTurnsByType === 'object') {
@@ -6744,18 +6869,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6744
6869
  const n = Number(val);
6745
6870
  if (n && n >= 5 && n <= 500) mbt[type] = n;
6746
6871
  }
6747
- config.engine.maxTurnsByType = mbt;
6872
+ _setEngineConfig('maxTurnsByType', mbt);
6748
6873
  }
6749
6874
  // Boolean fields
6750
6875
  const booleanFields = Object.keys(shared.ENGINE_DEFAULTS).filter(k => typeof shared.ENGINE_DEFAULTS[k] === 'boolean');
6751
6876
  for (const key of booleanFields) {
6752
- if (e[key] !== undefined) config.engine[key] = !!e[key];
6877
+ if (e[key] !== undefined) _setEngineConfig(key, !!e[key]);
6753
6878
  }
6754
6879
  // Eval loop settings
6755
- if (e.evalMaxIterations !== undefined) config.engine.evalMaxIterations = Math.max(1, Math.min(10, Number(e.evalMaxIterations) || D.evalMaxIterations));
6756
- if (e.evalMaxCost !== undefined) config.engine.evalMaxCost = e.evalMaxCost === null || e.evalMaxCost === '' ? null : Math.max(0, Number(e.evalMaxCost) || 0);
6880
+ if (e.evalMaxIterations !== undefined) _setEngineConfig('evalMaxIterations', Math.max(1, Math.min(10, Number(e.evalMaxIterations) || D.evalMaxIterations)));
6881
+ if (e.evalMaxCost !== undefined) _setEngineConfig('evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === '' ? null : Math.max(0, Number(e.evalMaxCost) || 0));
6757
6882
  if (e.ignoredCommentAuthors !== undefined) {
6758
- config.engine.ignoredCommentAuthors = String(e.ignoredCommentAuthors || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
6883
+ _setEngineConfig('ignoredCommentAuthors', String(e.ignoredCommentAuthors || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
6759
6884
  }
6760
6885
  }
6761
6886
 
@@ -6892,7 +7017,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6892
7017
  }
6893
7018
 
6894
7019
  shared.pruneDefaultClaudeConfig(config);
6895
- safeWrite(configPath, config);
7020
+ mutateDashboardConfig(current => mergeSettingsConfigUpdate(current, config, body, _configPatch));
6896
7021
  // Refresh in-memory CONFIG so subsequent reads see the update
6897
7022
  reloadConfig();
6898
7023
  invalidateStatusCache();
@@ -6915,11 +7040,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6915
7040
 
6916
7041
  async function handleSettingsReset(req, res) {
6917
7042
  try {
6918
- const config = queries.getConfig();
6919
- config.engine = { ...shared.ENGINE_DEFAULTS };
6920
- delete config.claude;
6921
- config.agents = { ...shared.DEFAULT_AGENTS };
6922
- safeWrite(path.join(MINIONS_DIR, 'config.json'), config);
7043
+ mutateDashboardConfig(config => {
7044
+ config.engine = { ...shared.ENGINE_DEFAULTS };
7045
+ delete config.claude;
7046
+ config.agents = { ...shared.DEFAULT_AGENTS };
7047
+ return config;
7048
+ });
6923
7049
  reloadConfig();
6924
7050
  invalidateStatusCache();
6925
7051
  return jsonReply(res, 200, { ok: true });
@@ -7189,18 +7315,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7189
7315
 
7190
7316
  // Pinned notes
7191
7317
  { method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
7192
- const content = safeRead(path.join(MINIONS_DIR, 'pinned.md'));
7318
+ const content = safeRead(PINNED_PATH);
7193
7319
  return jsonReply(res, 200, { content, entries: parsePinnedEntries(content) });
7194
7320
  }},
7195
7321
  { method: 'POST', path: '/api/pinned', desc: 'Add a pinned note', params: 'title, content, level?', handler: async (req, res) => {
7196
7322
  const body = await readBody(req);
7197
7323
  const { title, content, level } = body;
7198
7324
  if (!title || !content) return jsonReply(res, 400, { error: 'title and content required' });
7199
- const pinnedPath = path.join(MINIONS_DIR, 'pinned.md');
7200
- const existing = safeRead(pinnedPath);
7201
- const levelTag = level === 'critical' ? '🔴 ' : level === 'warning' ? '🟡 ' : '';
7202
- const entry = '\n\n### ' + levelTag + title + '\n\n' + content + '\n\n*Pinned by human on ' + new Date().toISOString().slice(0, 10) + '*';
7203
- safeWrite(pinnedPath, (existing || '# Pinned Context\n\nCritical notes visible to all agents.') + entry);
7325
+ addPinnedEntryLocked({ title, content, level });
7204
7326
  // pinned.md is in slow-state cache — opt-in invalidation so the new entry is visible immediately (closes #1295)
7205
7327
  invalidateStatusCache({ includeSlow: true });
7206
7328
  return jsonReply(res, 200, { ok: true });
@@ -7209,12 +7331,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7209
7331
  const body = await readBody(req);
7210
7332
  const { title } = body;
7211
7333
  if (!title) return jsonReply(res, 400, { error: 'title required' });
7212
- const pinnedPath = path.join(MINIONS_DIR, 'pinned.md');
7213
- let content = safeRead(pinnedPath);
7214
- if (!content) return jsonReply(res, 404, { error: 'No pinned notes' });
7215
- const regex = new RegExp('\\n\\n###\\s*(?:🔴\\s*|🟡\\s*)?' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\n[\\s\\S]*?(?=\\n\\n###|$)', 'i');
7216
- content = content.replace(regex, '');
7217
- safeWrite(pinnedPath, content);
7334
+ if (!removePinnedEntryLocked(title)) return jsonReply(res, 404, { error: 'No pinned notes' });
7218
7335
  // pinned.md is in slow-state cache — opt-in invalidation so the unpin is visible immediately
7219
7336
  invalidateStatusCache({ includeSlow: true });
7220
7337
  return jsonReply(res, 200, { ok: true });
@@ -7222,24 +7339,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7222
7339
 
7223
7340
  // KB pin state (server-side so CC can pin items)
7224
7341
  { method: 'GET', path: '/api/kb-pins', desc: 'Get pinned KB item keys', handler: async (req, res) => {
7225
- const pins = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'kb-pins.json')) || [];
7342
+ const pins = shared.safeJson(KB_PINS_PATH) || [];
7226
7343
  return jsonReply(res, 200, { pins });
7227
7344
  }},
7228
7345
  { method: 'POST', path: '/api/kb-pins', desc: 'Set pinned KB item keys', params: 'pins[]', handler: async (req, res) => {
7229
7346
  const body = await readBody(req);
7230
7347
  if (!Array.isArray(body.pins)) return jsonReply(res, 400, { error: 'pins array required' });
7231
- safeWrite(path.join(MINIONS_DIR, 'engine', 'kb-pins.json'), body.pins);
7348
+ setKbPinsLocked(body.pins);
7232
7349
  return jsonReply(res, 200, { ok: true });
7233
7350
  }},
7234
7351
  { method: 'POST', path: '/api/kb-pins/toggle', desc: 'Toggle a single KB pin', params: 'key', handler: async (req, res) => {
7235
7352
  const body = await readBody(req);
7236
7353
  if (!body.key) return jsonReply(res, 400, { error: 'key required' });
7237
- const pinsPath = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
7238
- const pins = shared.safeJson(pinsPath) || [];
7239
- const idx = pins.indexOf(body.key);
7240
- if (idx >= 0) pins.splice(idx, 1); else pins.unshift(body.key);
7241
- safeWrite(pinsPath, pins);
7242
- return jsonReply(res, 200, { ok: true, pinned: idx < 0 });
7354
+ const pinned = toggleKbPinLocked(body.key);
7355
+ return jsonReply(res, 200, { ok: true, pinned });
7243
7356
  }},
7244
7357
 
7245
7358
  // Notes
@@ -7916,6 +8029,7 @@ module.exports = {
7916
8029
  _formatCcCliCommandsIndex,
7917
8030
  _resetPreambleCache,
7918
8031
  _installCrashHandlers,
8032
+ _mergeSettingsConfigUpdate: mergeSettingsConfigUpdate,
7919
8033
  };
7920
8034
 
7921
8035
  // Start the HTTP server only when run directly (node dashboard.js).
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-07T23:06:07.639Z"
4
+ "cachedAt": "2026-05-07T23:22:07.269Z"
5
5
  }
@@ -1745,6 +1745,17 @@ async function rebaseBranchOntoMain(pr, project, config) {
1745
1745
  const tmpWt = path.join(wtRoot, `rebase-${shared.sanitizeBranch(branch)}-${Date.now()}`).replace(/\\/g, '/');
1746
1746
  const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true };
1747
1747
 
1748
+ // Refuse to create a worktree nested in the project root — would cause
1749
+ // glob/grep tools running with cwd=root to match both copies of every file
1750
+ // and produce mirror writes. Misconfigured engine.worktreeRoot is the only
1751
+ // way this lands inside root; the assert throws so the caller can recover.
1752
+ try {
1753
+ shared.assertWorktreeOutsideProject(tmpWt, root);
1754
+ } catch (err) {
1755
+ log('warn', `Post-merge rebase: refusing nested worktree path — ${err.message}`);
1756
+ return { success: false, error: err.message };
1757
+ }
1758
+
1748
1759
  try {
1749
1760
  await execAsync(`git fetch origin "${mainBranch}" "${branch}"`, _gitOpts);
1750
1761
  try {
@@ -238,8 +238,13 @@ function runPreflight(opts = {}) {
238
238
  // us the config. checkOrExit() / cli start() / doctor() pass it; legacy
239
239
  // callers don't, in which case we skip silently.
240
240
  if (opts && opts.config && typeof opts.config === 'object') {
241
+ // Hoisted: `shared` is referenced by every check block below, including
242
+ // workSources warnings and the worktreeRoot check. Previously declared
243
+ // inside the first inner try, which left later blocks reading an
244
+ // undefined identifier (ReferenceError silently caught by the wrapping
245
+ // try/catch).
246
+ const shared = require('./shared');
241
247
  try {
242
- const shared = require('./shared');
243
248
  let runtimeNames = [];
244
249
  try { runtimeNames = require('./runtimes').listRuntimes(); }
245
250
  catch { /* registry may be missing during partial installs */ }
@@ -266,6 +271,32 @@ function runPreflight(opts = {}) {
266
271
  results.push({ name: `Project config (${w.id})`, ok: 'warn', message: w.message });
267
272
  }
268
273
  } catch { /* defensive */ }
274
+
275
+ // 5. worktreeRoot config check — for every linked project, verify that the
276
+ // configured engine.worktreeRoot resolves OUTSIDE the project's
277
+ // localPath. A nested worktreeRoot causes glob/grep to match both
278
+ // copies of every file, producing silent mirror writes (the W-cc-doc-
279
+ // chat-continuity leak class). Hard-fail at preflight so the operator
280
+ // sees it before any agent dispatch — the runtime guard in spawnAgent
281
+ // is the second line of defense.
282
+ try {
283
+ const path = require('path');
284
+ const projects = shared.getProjects(opts.config) || [];
285
+ const wtRoot = opts.config?.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot;
286
+ for (const project of projects) {
287
+ if (!project || !project.localPath) continue;
288
+ const root = path.resolve(project.localPath);
289
+ const resolved = path.resolve(root, wtRoot);
290
+ if (shared.isPathInsideOrEqual(resolved, root)) {
291
+ results.push({
292
+ name: `worktreeRoot (${project.name || root})`,
293
+ ok: false,
294
+ message: `engine.worktreeRoot "${wtRoot}" resolves to "${resolved}" — INSIDE project root "${root}". This causes glob/grep to match duplicate files and produces mirror writes. Set engine.worktreeRoot to a sibling path (default: "../worktrees").`,
295
+ });
296
+ allOk = false;
297
+ }
298
+ }
299
+ } catch { /* defensive — preflight must never throw */ }
269
300
  }
270
301
 
271
302
  return { passed: allOk, results };
package/engine/shared.js CHANGED
@@ -421,6 +421,27 @@ function safeWrite(p, data) {
421
421
  }
422
422
  }
423
423
 
424
+ function mutateTextFileLocked(filePath, mutateFn, {
425
+ defaultValue = '',
426
+ lockRetries,
427
+ lockRetryBackoffMs,
428
+ skipWriteIfUnchanged = false
429
+ } = {}) {
430
+ const lockPath = `${filePath}.lock`;
431
+ const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
432
+ const retryBackoffMs = lockRetryBackoffMs ?? ENGINE_DEFAULTS.lockRetryBackoffMs;
433
+ return withFileLock(lockPath, () => {
434
+ const fileExists = fs.existsSync(filePath);
435
+ const before = fileExists ? safeRead(filePath) : String(defaultValue || '');
436
+ const next = mutateFn(before);
437
+ const finalText = next === undefined ? before : String(next);
438
+ if (!skipWriteIfUnchanged || finalText !== before) {
439
+ safeWrite(filePath, finalText);
440
+ }
441
+ return finalText;
442
+ }, { retries, retryBackoffMs });
443
+ }
444
+
424
445
  function safeUnlink(p) {
425
446
  try { fs.unlinkSync(p); } catch { /* cleanup */ }
426
447
  }
@@ -3146,6 +3167,7 @@ module.exports = {
3146
3167
  safeReadDir,
3147
3168
  safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore,
3148
3169
  safeWrite,
3170
+ mutateTextFileLocked,
3149
3171
  safeUnlink,
3150
3172
  resolveMinionsHome,
3151
3173
  saveMinionsRootPointer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1776",
3
+ "version": "0.1.1778",
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"