@yemi33/minions 0.1.1777 → 0.1.1779

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1779 (2026-05-07)
4
+
5
+ ### Features
6
+ - fix cc doc chat resume continuity (#2184)
7
+ - consolidate CC dispatch action type (#2183)
8
+ - harden dashboard state mutations (#2175)
9
+
3
10
  ## 0.1.1777 (2026-05-07)
4
11
 
5
12
  ### Fixes
@@ -103,15 +103,15 @@ function _ccActiveTab() {
103
103
 
104
104
  // Build a plain-text transcript from a tab's stored messages — sent on every
105
105
  // initial request so the server can carry it over if the session has to reset
106
- // (runtime switch, system-prompt change). Last 20 user/assistant turns only;
107
- // system/action rows are skipped because they're UI artifacts, not dialog.
106
+ // (runtime switch, system-prompt change) or if the previous turn has local
107
+ // action results the runtime session never saw.
108
108
  var CC_TRANSCRIPT_MAX_TURNS = 20;
109
109
  function _ccBuildTranscript(tab) {
110
110
  if (!tab || !Array.isArray(tab.messages) || tab.messages.length === 0) return [];
111
111
  var out = [];
112
112
  for (var i = 0; i < tab.messages.length; i++) {
113
113
  var m = tab.messages[i];
114
- if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
114
+ if (!m || (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'action' && m.role !== 'system')) continue;
115
115
  var html = typeof m.html === 'string' ? m.html : '';
116
116
  var tmp = document.createElement('div');
117
117
  tmp.innerHTML = html;
@@ -141,6 +141,23 @@ function _ccMergeStreamText(prev, incoming) {
141
141
  return current + '\n\n' + next;
142
142
  }
143
143
 
144
+ var CC_DISPATCH_ACTION_ALIASES = ['fix', 'implement', 'explore', 'review', 'test'];
145
+ function _ccNormalizeDispatchAction(action) {
146
+ if (!action || typeof action !== 'object' || typeof action.type !== 'string') return action;
147
+ var type = action.type.trim().toLowerCase();
148
+ if (type === 'dispatch') {
149
+ if (action.type === 'dispatch') return action;
150
+ var dispatchAction = Object.assign({}, action);
151
+ dispatchAction.type = 'dispatch';
152
+ return dispatchAction;
153
+ }
154
+ if (CC_DISPATCH_ACTION_ALIASES.indexOf(type) < 0) return action;
155
+ var normalized = Object.assign({}, action);
156
+ normalized.type = 'dispatch';
157
+ if (!normalized.workType) normalized.workType = type;
158
+ return normalized;
159
+ }
160
+
144
161
  async function _ccDashboardHealth() {
145
162
  var controller = new AbortController();
146
163
  var timer = setTimeout(function() { controller.abort(); }, 3000);
@@ -745,7 +762,7 @@ async function _ccDoSend(message, skipUserMsg, forceTabId) {
745
762
  if (!isReconnect && res.status === 429 && (!activeTab._429retries || activeTab._429retries < 3)) {
746
763
  activeTab._429retries = (activeTab._429retries || 0) + 1;
747
764
  await new Promise(function(r) { setTimeout(r, 1500); });
748
- return await _ccConsumeStream({ message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null }, false);
765
+ return await _ccConsumeStream({ message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null, transcript: _ccBuildTranscript(activeTab) }, false);
749
766
  }
750
767
  activeTab._429retries = 0;
751
768
  var errText = await res.text();
@@ -994,6 +1011,7 @@ function _tagServerExecuted(actions, actionResults) {
994
1011
  }
995
1012
 
996
1013
  async function ccExecuteAction(action, targetTabId) {
1014
+ action = _ccNormalizeDispatchAction(action);
997
1015
  var status = document.createElement('div');
998
1016
  status.style.cssText = 'padding:4px 10px;border-radius:4px;font-size:10px;align-self:flex-start;border:1px dashed var(--border);color:var(--muted)';
999
1017
 
@@ -1010,7 +1028,7 @@ async function ccExecuteAction(action, targetTabId) {
1010
1028
  status.style.color = action._serverDuplicate ? 'var(--orange)' : 'var(--green)';
1011
1029
  }
1012
1030
  ccAddMessage('action', status.outerHTML, false, targetTabId);
1013
- if (['dispatch','fix','implement','explore','review','test','create-meeting'].includes(action.type)) wakeEngine();
1031
+ if (['dispatch','create-meeting'].includes(action.type)) wakeEngine();
1014
1032
  refresh();
1015
1033
  return;
1016
1034
  }
@@ -1023,7 +1041,7 @@ async function ccExecuteAction(action, targetTabId) {
1023
1041
  case 'explore':
1024
1042
  case 'review':
1025
1043
  case 'test': {
1026
- var workType = action.workType || (action.type !== 'dispatch' ? action.type : 'implement');
1044
+ var workType = action.workType || 'implement';
1027
1045
  // Forward both singular (`agent`) and plural (`agents`) hint shapes —
1028
1046
  // the LLM emits either depending on phrasing ("assign to lambert" vs
1029
1047
  // "dispatch to dallas, ralph"). The server-side handler promotes a
@@ -28,6 +28,7 @@ let _qaAbortController = null;
28
28
  let _qaQueue = []; // queued messages while processing
29
29
  const QA_QUEUE_CAP = 10; // max queued messages
30
30
  const QA_STREAM_STALL_MS = 6 * 60 * 1000; // allow the full doc-chat timeout before treating heartbeat-only streams as stalled
31
+ const QA_TRANSCRIPT_MAX_TURNS = 20;
31
32
  let _qaSessionKey = ''; // key for current conversation (title or filePath)
32
33
 
33
34
  const QA_STICKY_BOTTOM_PX = 80;
@@ -97,6 +98,40 @@ function _qaCloneQueue(queue) {
97
98
  return Array.isArray(queue) ? queue.map(item => ({ ...item })) : [];
98
99
  }
99
100
 
101
+ function _qaBuildTranscript(history, currentMessage) {
102
+ if (!Array.isArray(history) || history.length === 0) return [];
103
+ const current = typeof currentMessage === 'string' ? currentMessage.trim() : '';
104
+ const out = [];
105
+ for (let i = 0; i < history.length; i++) {
106
+ const m = history[i];
107
+ if (!m || (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'action' && m.role !== 'system')) continue;
108
+ const text = typeof m.text === 'string' ? m.text.trim() : '';
109
+ if (!text) continue;
110
+ if (current && m.role === 'user' && text === current && i === history.length - 1) continue;
111
+ out.push({ role: m.role, text });
112
+ }
113
+ return out.slice(-QA_TRANSCRIPT_MAX_TURNS);
114
+ }
115
+
116
+ function _qaSummarizeActionContext(actions, actionResults) {
117
+ if (!Array.isArray(actions) || actions.length === 0) return '';
118
+ const lines = [];
119
+ for (let i = 0; i < actions.length; i++) {
120
+ const action = actions[i] || {};
121
+ const result = Array.isArray(actionResults) ? actionResults[i] : null;
122
+ const type = action.type || 'action';
123
+ const subject = result?.id || action.id || action.title || action.file || action.target || action.endpoint || '';
124
+ if (result?.error) {
125
+ lines.push(`${type}${subject ? ' ' + subject : ''} failed: ${result.error}`);
126
+ } else if (result?.ok || action._serverExecuted) {
127
+ lines.push(`${type}${subject ? ' ' + subject : ''} completed${result?.duplicate ? ' (duplicate)' : ''}${result?.warning ? ': ' + result.warning : ''}`);
128
+ } else {
129
+ lines.push(`${type}${subject ? ' ' + subject : ''} emitted`);
130
+ }
131
+ }
132
+ return lines.join('\n');
133
+ }
134
+
100
135
  function _qaGetRuntime(key) {
101
136
  if (!key) return null;
102
137
  let runtime = _qaRuntime.get(key);
@@ -609,6 +644,7 @@ async function _processQaMessage(message, selection, opts) {
609
644
  filePath: capturedFilePath || null,
610
645
  model: window._lastStatus?.autoMode?.ccModel || undefined,
611
646
  contentHash: capturedDocContext.content ? (function(s) { const m = Math.floor(s.length / 2); return s.length + ':' + s.charCodeAt(0) + ':' + s.charCodeAt(m) + ':' + s.charCodeAt(s.length - 1); })(capturedDocContext.content) : undefined,
647
+ transcript: _qaBuildTranscript(runtime.history, message),
612
648
  }),
613
649
  });
614
650
  let sessionDocContext = { ...capturedDocContext };
@@ -692,20 +728,21 @@ async function _processQaMessage(message, selection, opts) {
692
728
  if (rawErrorHtml) _qaInsertBeforeQueued(tmp, rawErrorHtml);
693
729
  });
694
730
 
695
- runtime.history.push({ role: 'user', text: message });
696
- runtime.history.push({ role: 'assistant', text: finalText || '' });
697
- if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
698
-
699
731
  _qaNotifySidebar(capturedFilePath);
732
+ runtime.history.push({ role: 'user', text: message });
733
+ runtime.history.push({ role: 'assistant', text: bodyText || finalText || '' });
700
734
  if (evt.actions && evt.actions.length > 0) {
701
735
  if (evt.actionResults && typeof _tagServerExecuted === 'function') _tagServerExecuted(evt.actions, evt.actionResults);
702
736
  for (const action of evt.actions) await ccExecuteAction(action);
737
+ const actionContext = _qaSummarizeActionContext(evt.actions, evt.actionResults);
738
+ if (actionContext) runtime.history.push({ role: 'action', text: actionContext });
703
739
  } else if (evt.actionParseError) {
704
740
  const warning = '<div class="modal-qa-a" style="color:var(--red)">Actions block emitted but JSON could not be parsed — no actions were executed. Resend or rephrase. (' + escHtml(String(evt.actionParseError).slice(0, 200)) + ')</div>';
705
741
  updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
706
742
  _qaInsertBeforeQueued(tmp, warning);
707
743
  });
708
744
  }
745
+ if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
709
746
 
710
747
  if (evt.edited && evt.content) {
711
748
  const display = evt.content.replace(/^---[\s\S]*?---\n*/m, '');
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,
@@ -1279,10 +1366,36 @@ function _readCcTabSessions({ prune = true } = {}) {
1279
1366
 
1280
1367
  const CC_CARRYOVER_MAX_TURNS = 20;
1281
1368
  const CC_CARRYOVER_PER_MSG_CHARS = 2000;
1369
+ const CC_TRANSCRIPT_DIALOG_ROLES = new Set(['user', 'assistant']);
1370
+ const CC_TRANSCRIPT_CONTEXT_ROLES = new Set(['user', 'assistant', 'action', 'system']);
1282
1371
 
1283
- function _buildTranscriptCarryover(transcript, { previousRuntime, currentMessage } = {}) {
1372
+ function _normalizeTranscriptRole(role) {
1373
+ const value = String(role || '').toLowerCase();
1374
+ return CC_TRANSCRIPT_CONTEXT_ROLES.has(value) ? value : null;
1375
+ }
1376
+
1377
+ function _transcriptHasCarryoverContext(transcript, { outOfBandOnly = false, currentMessage } = {}) {
1378
+ if (!Array.isArray(transcript)) return false;
1379
+ const current = typeof currentMessage === 'string' ? currentMessage.trim() : '';
1380
+ return transcript.some(m => {
1381
+ const role = _normalizeTranscriptRole(m?.role);
1382
+ if (!role || typeof m.text !== 'string' || !m.text.trim()) return false;
1383
+ if (outOfBandOnly && CC_TRANSCRIPT_DIALOG_ROLES.has(role)) return false;
1384
+ return !(current && role === 'user' && m.text.trim() === current);
1385
+ });
1386
+ }
1387
+
1388
+ function _buildTranscriptCarryover(transcript, { previousRuntime, currentMessage, outOfBandOnly = false } = {}) {
1284
1389
  if (!Array.isArray(transcript) || transcript.length === 0) return '';
1285
- let filtered = transcript.filter(m => m && (m.role === 'user' || m.role === 'assistant') && typeof m.text === 'string' && m.text.trim());
1390
+ let filtered = transcript
1391
+ .map(m => {
1392
+ const role = _normalizeTranscriptRole(m?.role);
1393
+ return role && typeof m?.text === 'string' && m.text.trim()
1394
+ ? { role, text: m.text }
1395
+ : null;
1396
+ })
1397
+ .filter(Boolean);
1398
+ if (outOfBandOnly) filtered = filtered.filter(m => !CC_TRANSCRIPT_DIALOG_ROLES.has(m.role));
1286
1399
  const current = typeof currentMessage === 'string' ? currentMessage.trim() : '';
1287
1400
  if (current && filtered.length > 0) {
1288
1401
  const last = filtered[filtered.length - 1];
@@ -1291,11 +1404,19 @@ function _buildTranscriptCarryover(transcript, { previousRuntime, currentMessage
1291
1404
  if (filtered.length === 0) return '';
1292
1405
  const recent = filtered.slice(-CC_CARRYOVER_MAX_TURNS);
1293
1406
  const truncated = filtered.length > recent.length;
1294
- const header = previousRuntime
1295
- ? `--- Previous conversation (carried over from ${previousRuntime} session) ---`
1296
- : `--- Previous conversation (carried over) ---`;
1407
+ const header = outOfBandOnly
1408
+ ? `--- Previous out-of-band UI/server events (carried over) ---`
1409
+ : previousRuntime
1410
+ ? `--- Previous conversation (carried over from ${previousRuntime} session) ---`
1411
+ : `--- Previous conversation (carried over) ---`;
1297
1412
  const lines = recent.map(m => {
1298
- const who = m.role === 'user' ? 'User' : 'Assistant';
1413
+ const who = m.role === 'user'
1414
+ ? 'User'
1415
+ : m.role === 'assistant'
1416
+ ? 'Assistant'
1417
+ : m.role === 'action'
1418
+ ? 'Action result'
1419
+ : 'System note';
1299
1420
  let text = m.text.trim();
1300
1421
  if (text.length > CC_CARRYOVER_PER_MSG_CHARS) text = text.slice(0, CC_CARRYOVER_PER_MSG_CHARS) + '… [truncated]';
1301
1422
  return `${who}: ${text}`;
@@ -1664,6 +1785,19 @@ function _extractActionsJson(segment) {
1664
1785
  return null;
1665
1786
  }
1666
1787
 
1788
+ const CC_DISPATCH_ACTION_ALIASES = new Set(['fix', 'implement', 'explore', 'review', 'test']);
1789
+
1790
+ function normalizeCCAction(action) {
1791
+ if (!action || typeof action !== 'object') return action;
1792
+ if (typeof action.type !== 'string') return action;
1793
+ const type = action.type.trim().toLowerCase();
1794
+ if (type === 'dispatch') {
1795
+ return action.type === 'dispatch' ? action : { ...action, type: 'dispatch' };
1796
+ }
1797
+ if (!CC_DISPATCH_ACTION_ALIASES.has(type)) return action;
1798
+ return { ...action, type: 'dispatch', workType: action.workType || type };
1799
+ }
1800
+
1667
1801
  function parseCCActions(text) {
1668
1802
  let actions = [];
1669
1803
  let displayText = stripCCActionsForDisplay(text);
@@ -1704,6 +1838,7 @@ function parseCCActions(text) {
1704
1838
  parseError = null; // legacy fallback recovered actions
1705
1839
  }
1706
1840
  }
1841
+ actions = actions.map(normalizeCCAction);
1707
1842
  const result = { text: displayText, actions };
1708
1843
  if (parseError && actions.length === 0) {
1709
1844
  result._actionParseError = parseError;
@@ -2099,7 +2234,8 @@ function _ccValidateAction(action) {
2099
2234
 
2100
2235
  async function executeCCActions(actions) {
2101
2236
  const results = [];
2102
- for (const action of actions) {
2237
+ for (const rawAction of actions) {
2238
+ const action = normalizeCCAction(rawAction);
2103
2239
  const validationError = _ccValidateAction(action);
2104
2240
  if (validationError) {
2105
2241
  results.push({ type: action?.type || 'unknown', error: validationError });
@@ -2108,7 +2244,7 @@ async function executeCCActions(actions) {
2108
2244
  try {
2109
2245
  switch (action.type) {
2110
2246
  case 'dispatch': case 'fix': case 'implement': case 'explore': case 'review': case 'test': {
2111
- const workType = routing.normalizeWorkType(action.workType || (action.type !== 'dispatch' ? action.type : WORK_TYPE.IMPLEMENT), WORK_TYPE.IMPLEMENT);
2247
+ const workType = routing.normalizeWorkType(action.workType || WORK_TYPE.IMPLEMENT, WORK_TYPE.IMPLEMENT);
2112
2248
  const id = 'W-' + shared.uid();
2113
2249
  const project = action.project || '';
2114
2250
  const prRef = getWorkItemPrRef(action);
@@ -2574,12 +2710,14 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2574
2710
  const existing = resolveSession(store, sessionKey);
2575
2711
  let sessionId = existing ? existing.sessionId : null;
2576
2712
  const resumeNeedsCarryover = !!sessionId && _ccRuntimeNeedsResumeCarryover(shared.resolveCcCli(CONFIG.engine));
2713
+ const resumeHasOutOfBandCarryover = !!sessionId && _transcriptHasCarryoverContext(transcript, { outOfBandOnly: true, currentMessage: message });
2714
+ const freshNeedsCarryover = _transcriptHasCarryoverContext(transcript, { currentMessage: message });
2577
2715
 
2578
- function buildPrompt({ includePreamble = true, includeCarryover = false } = {}) {
2716
+ function buildPrompt({ includePreamble = true, includeCarryover = false, outOfBandOnly = false } = {}) {
2579
2717
  const parts = (!skipStatePreamble && includePreamble) ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
2580
2718
  if (extraContext) parts.push(extraContext);
2581
2719
  if (includeCarryover) {
2582
- const carryover = _buildTranscriptCarryover(transcript, { currentMessage: message });
2720
+ const carryover = _buildTranscriptCarryover(transcript, { currentMessage: message, outOfBandOnly });
2583
2721
  if (carryover) parts.push(carryover);
2584
2722
  }
2585
2723
  parts.push(message);
@@ -2590,7 +2728,11 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2590
2728
 
2591
2729
  // Attempt 1: resume existing session — skip preamble (session already has context)
2592
2730
  if (sessionId && maxTurns > 1) {
2593
- const p1 = llm.callLLM(buildPrompt({ includePreamble: false, includeCarryover: resumeNeedsCarryover }), '', {
2731
+ const p1 = llm.callLLM(buildPrompt({
2732
+ includePreamble: false,
2733
+ includeCarryover: resumeNeedsCarryover || resumeHasOutOfBandCarryover,
2734
+ outOfBandOnly: !resumeNeedsCarryover,
2735
+ }), '', {
2594
2736
  timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort, direct: true,
2595
2737
  engineConfig: CONFIG.engine,
2596
2738
  });
@@ -2627,7 +2769,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
2627
2769
  }
2628
2770
 
2629
2771
  // Attempt 2: fresh session (include preamble for full context)
2630
- const freshPrompt = buildPrompt({ includeCarryover: resumeNeedsCarryover });
2772
+ const freshPrompt = buildPrompt({ includeCarryover: freshNeedsCarryover });
2631
2773
  const p2 = llm.callLLM(freshPrompt, systemPrompt, {
2632
2774
  timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
2633
2775
  engineConfig: CONFIG.engine,
@@ -2675,12 +2817,14 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2675
2817
  const existing = resolveSession(store, sessionKey);
2676
2818
  let sessionId = existing ? existing.sessionId : null;
2677
2819
  const resumeNeedsCarryover = !!sessionId && _ccRuntimeNeedsResumeCarryover(shared.resolveCcCli(CONFIG.engine));
2820
+ const resumeHasOutOfBandCarryover = !!sessionId && _transcriptHasCarryoverContext(transcript, { outOfBandOnly: true, currentMessage: message });
2821
+ const freshNeedsCarryover = _transcriptHasCarryoverContext(transcript, { currentMessage: message });
2678
2822
 
2679
- function buildPrompt({ includePreamble = true, includeCarryover = false } = {}) {
2823
+ function buildPrompt({ includePreamble = true, includeCarryover = false, outOfBandOnly = false } = {}) {
2680
2824
  const parts = (!skipStatePreamble && includePreamble) ? [`## Current Minions State (${new Date().toISOString().slice(0, 16)})\n\n${buildCCStatePreamble()}`] : [];
2681
2825
  if (extraContext) parts.push(extraContext);
2682
2826
  if (includeCarryover) {
2683
- const carryover = _buildTranscriptCarryover(transcript, { currentMessage: message });
2827
+ const carryover = _buildTranscriptCarryover(transcript, { currentMessage: message, outOfBandOnly });
2684
2828
  if (carryover) parts.push(carryover);
2685
2829
  }
2686
2830
  parts.push(message);
@@ -2690,7 +2834,11 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2690
2834
  let result;
2691
2835
 
2692
2836
  if (sessionId && maxTurns > 1) {
2693
- const p1 = llm.callLLMStreaming(buildPrompt({ includePreamble: false, includeCarryover: resumeNeedsCarryover }), '', {
2837
+ const p1 = llm.callLLMStreaming(buildPrompt({
2838
+ includePreamble: false,
2839
+ includeCarryover: resumeNeedsCarryover || resumeHasOutOfBandCarryover,
2840
+ outOfBandOnly: !resumeNeedsCarryover,
2841
+ }), '', {
2694
2842
  timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort, direct: true,
2695
2843
  engineConfig: CONFIG.engine,
2696
2844
  onChunk,
@@ -2727,7 +2875,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
2727
2875
  }
2728
2876
 
2729
2877
  if (onRetry) onRetry(2);
2730
- const freshPrompt = buildPrompt({ includeCarryover: resumeNeedsCarryover });
2878
+ const freshPrompt = buildPrompt({ includeCarryover: freshNeedsCarryover });
2731
2879
  const p2 = llm.callLLMStreaming(freshPrompt, systemPrompt, {
2732
2880
  timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
2733
2881
  engineConfig: CONFIG.engine,
@@ -3099,7 +3247,7 @@ function _makeDocChatStreamStripper(onChunk) {
3099
3247
  }
3100
3248
 
3101
3249
  // Doc-specific wrapper — adds document context, parses ---DOCUMENT---
3102
- async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
3250
+ async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady }) {
3103
3251
  const sessionKey = filePath || title;
3104
3252
  const docSlice = String(document || '');
3105
3253
 
@@ -3126,6 +3274,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
3126
3274
  allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
3127
3275
  skipStatePreamble: true,
3128
3276
  systemPrompt: DOC_CHAT_SYSTEM_PROMPT,
3277
+ transcript,
3129
3278
  ...(model ? { model } : {}),
3130
3279
  onAbortReady,
3131
3280
  });
@@ -3163,7 +3312,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
3163
3312
  return _parseDocChatResultText(result.text);
3164
3313
  }
3165
3314
 
3166
- async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady, onChunk, onToolUse, onRetry }) {
3315
+ async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady, onChunk, onToolUse, onRetry }) {
3167
3316
  const sessionKey = filePath || title;
3168
3317
  const docSlice = String(document || '');
3169
3318
  const streamStripper = _makeDocChatStreamStripper(onChunk);
@@ -3186,6 +3335,7 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
3186
3335
  allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
3187
3336
  skipStatePreamble: true,
3188
3337
  systemPrompt: DOC_CHAT_SYSTEM_PROMPT,
3338
+ transcript,
3189
3339
  ...(model ? { model } : {}),
3190
3340
  onAbortReady,
3191
3341
  onChunk: streamStripper,
@@ -5346,6 +5496,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5346
5496
  filePath: body.filePath, selection: body.selection, canEdit, isJson,
5347
5497
  model: body.model || undefined,
5348
5498
  freshSession: !!body.freshSession,
5499
+ transcript: body.transcript,
5349
5500
  onAbortReady: (abort) => { _docAbort = abort; },
5350
5501
  });
5351
5502
  const actionResults = await executeDocChatActions(actions);
@@ -5436,6 +5587,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5436
5587
  filePath: body.filePath, selection: body.selection, canEdit, isJson,
5437
5588
  model: body.model || undefined,
5438
5589
  freshSession: !!body.freshSession,
5590
+ transcript: body.transcript,
5439
5591
  onAbortReady: (abort) => { _docAbort = abort; },
5440
5592
  onChunk: (text) => { writeDocEvent({ type: 'chunk', text }); },
5441
5593
  onToolUse: (name, input) => { writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
@@ -5734,13 +5886,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5734
5886
  });
5735
5887
  }
5736
5888
 
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)) {
5889
+ // Check if already linked under the config lock so concurrent dashboard
5890
+ // adds cannot both pass the preflight check and then clobber config.json.
5891
+ let alreadyLinked = false;
5892
+ mutateDashboardConfig(config => {
5893
+ if (!Array.isArray(config.projects)) config.projects = [];
5894
+ alreadyLinked = config.projects.some(p => path.resolve(p.localPath) === target);
5895
+ return config;
5896
+ });
5897
+ if (alreadyLinked) {
5744
5898
  return jsonReply(res, 400, { error: 'Project already linked at ' + target });
5745
5899
  }
5746
5900
 
@@ -5779,8 +5933,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5779
5933
  // .minions state without leaving repo-local state files behind.
5780
5934
  shared.ensureProjectStateFiles(project, { migrateLegacy: true, removeLegacy: true });
5781
5935
 
5782
- config.projects.push(project);
5783
- safeWrite(configPath, config);
5936
+ let duplicate = false;
5937
+ mutateDashboardConfig(config => {
5938
+ if (!Array.isArray(config.projects)) config.projects = [];
5939
+ if (config.projects.some(p => path.resolve(p.localPath) === target)) {
5940
+ duplicate = true;
5941
+ return config;
5942
+ }
5943
+ config.projects.push(project);
5944
+ return config;
5945
+ });
5946
+ if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
5784
5947
  reloadConfig(); // Update in-memory project list immediately
5785
5948
  invalidateStatusCache();
5786
5949
 
@@ -6181,11 +6344,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6181
6344
  const wasResume = !!tabSessionId;
6182
6345
  const sessionId = tabSessionId || null;
6183
6346
  const resumeNeedsCarryover = wasResume && _ccRuntimeNeedsResumeCarryover(currentRuntime);
6347
+ const resumeHasOutOfBandCarryover = wasResume && _transcriptHasCarryoverContext(body.transcript, { outOfBandOnly: true, currentMessage: body.message });
6184
6348
  const preamble = wasResume ? '' : buildCCStatePreamble();
6185
- const carryover = (sessionReset || resumeNeedsCarryover)
6349
+ const includeFullCarryover = sessionReset || resumeNeedsCarryover;
6350
+ const carryover = (includeFullCarryover || resumeHasOutOfBandCarryover)
6186
6351
  ? _buildTranscriptCarryover(body.transcript, {
6187
6352
  previousRuntime: sessionReset ? previousRuntime : null,
6188
6353
  currentMessage: body.message,
6354
+ outOfBandOnly: !includeFullCarryover,
6189
6355
  })
6190
6356
  : '';
6191
6357
  const prompt = _joinCcPromptParts(preamble, carryover, body.message);
@@ -6358,24 +6524,25 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6358
6524
  if (!id) id = 'schedule';
6359
6525
  }
6360
6526
 
6527
+ let sched;
6528
+ mutateDashboardConfig(config => {
6529
+ if (!Array.isArray(config.schedules)) config.schedules = [];
6530
+ // If auto-generated ID collides, append a short numeric suffix
6531
+ let scheduleId = id;
6532
+ if (config.schedules.some(s => s.id === scheduleId)) {
6533
+ let suffix = 2;
6534
+ while (config.schedules.some(s => s.id === `${scheduleId}-${suffix}`)) suffix++;
6535
+ scheduleId = `${scheduleId}-${suffix}`;
6536
+ }
6537
+ sched = { id: scheduleId, cron, title, type: type || 'implement', enabled: enabled !== false };
6538
+ if (project) sched.project = project;
6539
+ if (agent) sched.agent = agent;
6540
+ if (description) sched.description = description;
6541
+ if (priority) sched.priority = priority;
6542
+ config.schedules.push(sched);
6543
+ return config;
6544
+ });
6361
6545
  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
6546
  invalidateStatusCache();
6380
6547
  return jsonReply(res, 200, { ok: true, schedule: sched });
6381
6548
  }
@@ -6385,21 +6552,28 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6385
6552
  const { id, cron, title, type, project, agent, description, priority, enabled } = body;
6386
6553
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6387
6554
 
6388
- reloadConfig();
6389
- if (!CONFIG.schedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6390
- const sched = CONFIG.schedules.find(s => s.id === id);
6555
+ let missingSchedules = false;
6556
+ let sched = null;
6557
+ mutateDashboardConfig(config => {
6558
+ if (!Array.isArray(config.schedules)) {
6559
+ missingSchedules = true;
6560
+ return config;
6561
+ }
6562
+ sched = config.schedules.find(s => s.id === id);
6563
+ if (!sched) return config;
6564
+ if (cron !== undefined) sched.cron = cron;
6565
+ if (title !== undefined) sched.title = title;
6566
+ if (type !== undefined) sched.type = type;
6567
+ if (project !== undefined) sched.project = project || null;
6568
+ if (agent !== undefined) sched.agent = agent || null;
6569
+ if (description !== undefined) sched.description = description;
6570
+ if (priority !== undefined) sched.priority = priority;
6571
+ if (enabled !== undefined) sched.enabled = enabled;
6572
+ return config;
6573
+ });
6574
+ if (missingSchedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6391
6575
  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);
6576
+ reloadConfig();
6403
6577
  invalidateStatusCache();
6404
6578
  return jsonReply(res, 200, { ok: true, schedule: sched });
6405
6579
  }
@@ -6409,13 +6583,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6409
6583
  const { id } = body;
6410
6584
  if (!id) return jsonReply(res, 400, { error: 'id required' });
6411
6585
 
6586
+ let missingSchedules = false;
6587
+ let deleted = false;
6588
+ mutateDashboardConfig(config => {
6589
+ if (!Array.isArray(config.schedules)) {
6590
+ missingSchedules = true;
6591
+ return config;
6592
+ }
6593
+ const idx = config.schedules.findIndex(s => s.id === id);
6594
+ if (idx < 0) return config;
6595
+ config.schedules.splice(idx, 1);
6596
+ deleted = true;
6597
+ return config;
6598
+ });
6599
+ if (missingSchedules) return jsonReply(res, 404, { error: 'No schedules configured' });
6600
+ if (!deleted) return jsonReply(res, 404, { error: 'Schedule not found' });
6412
6601
  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
6602
  invalidateStatusCache();
6420
6603
  return jsonReply(res, 200, { ok: true });
6421
6604
  }
@@ -6588,8 +6771,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6588
6771
  async function handleSettingsUpdate(req, res) {
6589
6772
  try {
6590
6773
  const body = await readBody(req);
6591
- const configPath = path.join(MINIONS_DIR, 'config.json');
6592
- const config = safeJson(configPath) || {};
6774
+ const config = safeJson(CONFIG_PATH) || {};
6593
6775
  if (!config.engine) config.engine = {};
6594
6776
  if (!config.agents) config.agents = {};
6595
6777
  shared.pruneDefaultClaudeConfig(config);
@@ -6597,6 +6779,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6597
6779
  const _clamped = [];
6598
6780
  const _engineModelDiscovery = require('./engine/model-discovery');
6599
6781
  const _engineRuntimes = require('./engine/runtimes');
6782
+ const _configPatch = { engine: { set: {}, delete: new Set() } };
6783
+ function _setEngineConfig(key, value) {
6784
+ config.engine[key] = value;
6785
+ _configPatch.engine.set[key] = value;
6786
+ _configPatch.engine.delete.delete(key);
6787
+ }
6788
+ function _deleteEngineConfig(key) {
6789
+ delete config.engine[key];
6790
+ delete _configPatch.engine.set[key];
6791
+ _configPatch.engine.delete.add(key);
6792
+ }
6600
6793
  function _resolveModelForRuntime(modelStr, runtimeName) {
6601
6794
  try {
6602
6795
  const adapter = _engineRuntimes.resolveRuntime(runtimeName);
@@ -6630,13 +6823,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6630
6823
  val = Math.max(min, val);
6631
6824
  if (max !== undefined) val = Math.min(max, val);
6632
6825
  if (val !== raw) _clamped.push(`${key}: ${raw} → ${val} (range: ${min}–${max || '∞'})`);
6633
- config.engine[key] = val;
6826
+ _setEngineConfig(key, val);
6634
6827
  }
6635
6828
  }
6636
- delete config.engine.adoPollStatusEvery;
6637
- delete config.engine.adoPollCommentsEvery;
6829
+ _deleteEngineConfig('adoPollStatusEvery');
6830
+ _deleteEngineConfig('adoPollCommentsEvery');
6638
6831
  // String fields
6639
- if (e.worktreeRoot !== undefined) config.engine.worktreeRoot = String(e.worktreeRoot || D.worktreeRoot);
6832
+ if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
6640
6833
 
6641
6834
  // ── Runtime fleet (P-7a5c1f8e) ─────────────────────────────────────
6642
6835
  // Empty string clears the override — the dashboard's "Default (CLI
@@ -6653,13 +6846,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6653
6846
  return _registeredCliNames.length === 0 || _registeredCliNames.includes(String(name));
6654
6847
  };
6655
6848
  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);
6849
+ if (_isClear(e.defaultCli)) _deleteEngineConfig('defaultCli');
6850
+ else if (_validCli(e.defaultCli)) _setEngineConfig('defaultCli', String(e.defaultCli));
6658
6851
  else _clamped.push(`defaultCli: "${e.defaultCli}" not registered (kept previous value)`);
6659
6852
  }
6660
6853
  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);
6854
+ if (_isClear(e.ccCli)) _deleteEngineConfig('ccCli');
6855
+ else if (_validCli(e.ccCli)) _setEngineConfig('ccCli', String(e.ccCli));
6663
6856
  else _clamped.push(`ccCli: "${e.ccCli}" not registered (kept previous value)`);
6664
6857
  }
6665
6858
  // Validate fleet-level model assignments against the resolved runtime.
@@ -6695,47 +6888,47 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6695
6888
  return null;
6696
6889
  }
6697
6890
  if (e.defaultModel !== undefined) {
6698
- if (_isClear(e.defaultModel)) delete config.engine.defaultModel;
6891
+ if (_isClear(e.defaultModel)) _deleteEngineConfig('defaultModel');
6699
6892
  else {
6700
6893
  const candidate = String(e.defaultModel);
6701
6894
  const resolvedCli = config.engine.defaultCli || 'claude';
6702
6895
  const rejection = await _validateFleetModel(candidate, resolvedCli);
6703
6896
  if (rejection) _clamped.push(`engine.defaultModel: "${candidate}" ${rejection} — kept previous value`);
6704
- else config.engine.defaultModel = candidate;
6897
+ else _setEngineConfig('defaultModel', candidate);
6705
6898
  }
6706
6899
  }
6707
6900
  if (e.ccModel !== undefined) {
6708
- if (_isClear(e.ccModel)) delete config.engine.ccModel;
6901
+ if (_isClear(e.ccModel)) _deleteEngineConfig('ccModel');
6709
6902
  else {
6710
6903
  const candidate = String(e.ccModel);
6711
6904
  const resolvedCli = config.engine.ccCli || config.engine.defaultCli || 'claude';
6712
6905
  const rejection = await _validateFleetModel(candidate, resolvedCli);
6713
6906
  if (rejection) _clamped.push(`engine.ccModel: "${candidate}" ${rejection} — kept previous value`);
6714
- else config.engine.ccModel = candidate;
6907
+ else _setEngineConfig('ccModel', candidate);
6715
6908
  }
6716
6909
  }
6717
6910
  if (e.claudeFallbackModel !== undefined) {
6718
- if (_isClear(e.claudeFallbackModel)) delete config.engine.claudeFallbackModel;
6719
- else config.engine.claudeFallbackModel = String(e.claudeFallbackModel);
6911
+ if (_isClear(e.claudeFallbackModel)) _deleteEngineConfig('claudeFallbackModel');
6912
+ else _setEngineConfig('claudeFallbackModel', String(e.claudeFallbackModel));
6720
6913
  }
6721
6914
  if (e.copilotStreamMode !== undefined) {
6722
6915
  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;
6916
+ if (_isClear(e.copilotStreamMode)) _deleteEngineConfig('copilotStreamMode');
6917
+ else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
6725
6918
  else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
6726
6919
  }
6727
6920
  // maxBudgetUsd uses ?? semantics — 0 is a valid cap (read-only / dry-run agents).
6728
6921
  if (e.maxBudgetUsd !== undefined) {
6729
- if (_isClear(e.maxBudgetUsd)) delete config.engine.maxBudgetUsd;
6922
+ if (_isClear(e.maxBudgetUsd)) _deleteEngineConfig('maxBudgetUsd');
6730
6923
  else {
6731
6924
  const n = Number(e.maxBudgetUsd);
6732
- if (Number.isFinite(n) && n >= 0) config.engine.maxBudgetUsd = n;
6925
+ if (Number.isFinite(n) && n >= 0) _setEngineConfig('maxBudgetUsd', n);
6733
6926
  else _clamped.push(`maxBudgetUsd: "${e.maxBudgetUsd}" must be ≥ 0 (kept previous value)`);
6734
6927
  }
6735
6928
  }
6736
6929
  if (e.ccEffort !== undefined) {
6737
6930
  const valid = [null, 'low', 'medium', 'high'];
6738
- config.engine.ccEffort = valid.includes(e.ccEffort) ? e.ccEffort : null;
6931
+ _setEngineConfig('ccEffort', valid.includes(e.ccEffort) ? e.ccEffort : null);
6739
6932
  }
6740
6933
  // Per-type max turns
6741
6934
  if (e.maxTurnsByType !== undefined && typeof e.maxTurnsByType === 'object') {
@@ -6744,18 +6937,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6744
6937
  const n = Number(val);
6745
6938
  if (n && n >= 5 && n <= 500) mbt[type] = n;
6746
6939
  }
6747
- config.engine.maxTurnsByType = mbt;
6940
+ _setEngineConfig('maxTurnsByType', mbt);
6748
6941
  }
6749
6942
  // Boolean fields
6750
6943
  const booleanFields = Object.keys(shared.ENGINE_DEFAULTS).filter(k => typeof shared.ENGINE_DEFAULTS[k] === 'boolean');
6751
6944
  for (const key of booleanFields) {
6752
- if (e[key] !== undefined) config.engine[key] = !!e[key];
6945
+ if (e[key] !== undefined) _setEngineConfig(key, !!e[key]);
6753
6946
  }
6754
6947
  // 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);
6948
+ if (e.evalMaxIterations !== undefined) _setEngineConfig('evalMaxIterations', Math.max(1, Math.min(10, Number(e.evalMaxIterations) || D.evalMaxIterations)));
6949
+ if (e.evalMaxCost !== undefined) _setEngineConfig('evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === '' ? null : Math.max(0, Number(e.evalMaxCost) || 0));
6757
6950
  if (e.ignoredCommentAuthors !== undefined) {
6758
- config.engine.ignoredCommentAuthors = String(e.ignoredCommentAuthors || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
6951
+ _setEngineConfig('ignoredCommentAuthors', String(e.ignoredCommentAuthors || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
6759
6952
  }
6760
6953
  }
6761
6954
 
@@ -6892,7 +7085,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6892
7085
  }
6893
7086
 
6894
7087
  shared.pruneDefaultClaudeConfig(config);
6895
- safeWrite(configPath, config);
7088
+ mutateDashboardConfig(current => mergeSettingsConfigUpdate(current, config, body, _configPatch));
6896
7089
  // Refresh in-memory CONFIG so subsequent reads see the update
6897
7090
  reloadConfig();
6898
7091
  invalidateStatusCache();
@@ -6915,11 +7108,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6915
7108
 
6916
7109
  async function handleSettingsReset(req, res) {
6917
7110
  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);
7111
+ mutateDashboardConfig(config => {
7112
+ config.engine = { ...shared.ENGINE_DEFAULTS };
7113
+ delete config.claude;
7114
+ config.agents = { ...shared.DEFAULT_AGENTS };
7115
+ return config;
7116
+ });
6923
7117
  reloadConfig();
6924
7118
  invalidateStatusCache();
6925
7119
  return jsonReply(res, 200, { ok: true });
@@ -7189,18 +7383,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7189
7383
 
7190
7384
  // Pinned notes
7191
7385
  { method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
7192
- const content = safeRead(path.join(MINIONS_DIR, 'pinned.md'));
7386
+ const content = safeRead(PINNED_PATH);
7193
7387
  return jsonReply(res, 200, { content, entries: parsePinnedEntries(content) });
7194
7388
  }},
7195
7389
  { method: 'POST', path: '/api/pinned', desc: 'Add a pinned note', params: 'title, content, level?', handler: async (req, res) => {
7196
7390
  const body = await readBody(req);
7197
7391
  const { title, content, level } = body;
7198
7392
  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);
7393
+ addPinnedEntryLocked({ title, content, level });
7204
7394
  // pinned.md is in slow-state cache — opt-in invalidation so the new entry is visible immediately (closes #1295)
7205
7395
  invalidateStatusCache({ includeSlow: true });
7206
7396
  return jsonReply(res, 200, { ok: true });
@@ -7209,12 +7399,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7209
7399
  const body = await readBody(req);
7210
7400
  const { title } = body;
7211
7401
  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);
7402
+ if (!removePinnedEntryLocked(title)) return jsonReply(res, 404, { error: 'No pinned notes' });
7218
7403
  // pinned.md is in slow-state cache — opt-in invalidation so the unpin is visible immediately
7219
7404
  invalidateStatusCache({ includeSlow: true });
7220
7405
  return jsonReply(res, 200, { ok: true });
@@ -7222,24 +7407,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7222
7407
 
7223
7408
  // KB pin state (server-side so CC can pin items)
7224
7409
  { 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')) || [];
7410
+ const pins = shared.safeJson(KB_PINS_PATH) || [];
7226
7411
  return jsonReply(res, 200, { pins });
7227
7412
  }},
7228
7413
  { method: 'POST', path: '/api/kb-pins', desc: 'Set pinned KB item keys', params: 'pins[]', handler: async (req, res) => {
7229
7414
  const body = await readBody(req);
7230
7415
  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);
7416
+ setKbPinsLocked(body.pins);
7232
7417
  return jsonReply(res, 200, { ok: true });
7233
7418
  }},
7234
7419
  { method: 'POST', path: '/api/kb-pins/toggle', desc: 'Toggle a single KB pin', params: 'key', handler: async (req, res) => {
7235
7420
  const body = await readBody(req);
7236
7421
  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 });
7422
+ const pinned = toggleKbPinLocked(body.key);
7423
+ return jsonReply(res, 200, { ok: true, pinned });
7243
7424
  }},
7244
7425
 
7245
7426
  // Notes
@@ -7479,8 +7660,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7479
7660
  { method: 'GET', path: /^\/api\/knowledge\/([^/]+)\/([^?]+)/, template: '/api/knowledge/:category/:file', desc: 'Read a specific knowledge base entry', handler: handleKnowledgeRead },
7480
7661
 
7481
7662
  // Doc chat
7482
- { method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChat },
7483
- { method: 'POST', path: '/api/doc-chat/stream', desc: 'Streaming doc chat — SSE with text chunks and tool progress', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChatStream },
7663
+ { method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?, transcript?', handler: handleDocChat },
7664
+ { method: 'POST', path: '/api/doc-chat/stream', desc: 'Streaming doc chat — SSE with text chunks and tool progress', params: 'message, document, title?, filePath?, selection?, contentHash?, transcript?', handler: handleDocChatStream },
7484
7665
 
7485
7666
  // Inbox
7486
7667
  { method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
@@ -7504,8 +7685,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7504
7685
  // Command Center
7505
7686
  { method: 'POST', path: '/api/command-center/new-session', desc: 'Clear active CC session', handler: handleCommandCenterNewSession },
7506
7687
  { method: 'POST', path: '/api/command-center/abort', desc: 'Abort an in-flight CC request for a tab', params: 'tabId?', handler: handleCommandCenterAbort },
7507
- { method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, sessionId?', handler: handleCommandCenter },
7508
- { method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message, tabId?', handler: handleCommandCenterStream },
7688
+ { method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, tabId?, sessionId?, transcript?', handler: handleCommandCenter },
7689
+ { method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message, tabId?, sessionId?, transcript?', handler: handleCommandCenterStream },
7509
7690
  { method: 'GET', path: '/api/cc-sessions', desc: 'List CC session metadata for all tabs', handler: handleCCSessionsList },
7510
7691
  { method: 'DELETE', path: /^\/api\/cc-sessions\/([\w-]+)$/, template: '/api/cc-sessions/:id', desc: 'Delete a CC session by tab ID', handler: handleCCSessionDelete },
7511
7692
 
@@ -7909,6 +8090,7 @@ module.exports = {
7909
8090
  buildCCStatePreamble,
7910
8091
  _routesAsMeta,
7911
8092
  _buildTranscriptCarryover,
8093
+ _transcriptHasCarryoverContext,
7912
8094
  _ccRuntimeNeedsResumeCarryover,
7913
8095
  _joinCcPromptParts,
7914
8096
  _captureApiRoutesMeta,
@@ -7916,6 +8098,7 @@ module.exports = {
7916
8098
  _formatCcCliCommandsIndex,
7917
8099
  _resetPreambleCache,
7918
8100
  _installCrashHandlers,
8101
+ _mergeSettingsConfigUpdate: mergeSettingsConfigUpdate,
7919
8102
  };
7920
8103
 
7921
8104
  // 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:17:20.330Z"
4
+ "cachedAt": "2026-05-07T23:23:28.356Z"
5
5
  }
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.1777",
3
+ "version": "0.1.1779",
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"
@@ -85,13 +85,13 @@ I'll dispatch dallas to fix that bug.
85
85
 
86
86
  **Required fields per action type — server rejects with an error if missing:**
87
87
 
88
- - `dispatch` (and aliases: `fix`, `implement`, `explore`, `review`, `test`): `title` is REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). For agent hints emit either `agents: ["dallas"]` (array, preferred) or `agent: "dallas"` (string — auto-promoted server-side). Unknown agent names error.
88
+ - `dispatch`: `title` is REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). For agent hints emit either `agents: ["dallas"]` (array, preferred) or `agent: "dallas"` (string — auto-promoted server-side). Unknown agent names error. Always emit `"type":"dispatch"` for dispatch-like work and preserve the semantic intent in `workType` (`fix`, `implement`, `explore`, `review`, or `test`) instead of using those words as action types.
89
89
  - `build-and-test`: `pr` REQUIRED (number, ID, or URL).
90
90
  - `note`: `title` and `content` (or `description`) REQUIRED.
91
91
  - `knowledge`: `title`, `content`, and `category` REQUIRED. Valid categories: architecture, conventions, project-notes, build-reports, reviews.
92
92
 
93
93
  Core action types:
94
- - **dispatch**: title (REQUIRED), workType, priority (low/medium/high), agents[] or agent (optional — both shapes accepted), project (REQUIRED when multi-project unless `pr` resolves to a tracked PR), description, pr (optional PR number/id/url for work that targets an existing PR), scope (`"fan-out"` only when the user explicitly asks to fan out to all agents)
94
+ - **dispatch**: title (REQUIRED), workType, priority (low/medium/high), agents[] or agent (optional — both shapes accepted), project (REQUIRED when multi-project unless `pr` resolves to a tracked PR), description, pr (optional PR number/id/url for work that targets an existing PR), scope (`"fan-out"` only when the user explicitly asks to fan out to all agents). Do not emit `type:"fix"` or `type:"implement"`; emit `type:"dispatch"` with `workType:"fix"` or `workType:"implement"`.
95
95
  workTypes: `explore` (research/report only, NO PR), `ask` (answer/report, NO PR), `implement` (new code, PR REQUIRED), `fix` (standalone bug fix creates a PR; include `pr` when fixing review comments/build failures on an existing PR), `review` (code review, NO PR), `test` (tests, PR if new), `verify` (merge/build/maintenance, NO PR)
96
96
  If the user wants a design/architecture artifact committed through a PR, dispatch `implement` or `docs` rather than `explore`.
97
97
  When the user names a specific agent ("assign this to lambert"), put exactly that one name in `agents` (e.g. `"agents": ["lambert"]`). A single-agent assignment is hard-pinned by the server — it will queue for that agent only and skip the routing table. If the user explicitly asks for fan-out/all agents, set `scope: "fan-out"`.