@yemi33/minions 0.1.2381 → 0.1.2382
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/bin/minions.js +41 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +84 -15
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +73 -21
- package/engine/lifecycle.js +70 -12
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +35 -0
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +63 -44
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +251 -16
- package/package.json +1 -1
package/bin/minions.js
CHANGED
|
@@ -336,7 +336,11 @@ function _sqliteSpawnFlags() {
|
|
|
336
336
|
* CLI's intent (EADDRINUSE retries still apply inside the dashboard if a
|
|
337
337
|
* race opens between the CLI's port-free check and the actual bind). */
|
|
338
338
|
function spawnDashboard(requestedPort) {
|
|
339
|
-
const env = {
|
|
339
|
+
const env = {
|
|
340
|
+
...process.env,
|
|
341
|
+
MINIONS_NO_AUTO_OPEN: '1',
|
|
342
|
+
MINIONS_REQUIRE_DASHBOARD_PORT: '1',
|
|
343
|
+
};
|
|
340
344
|
const out = _openStdioLog('dashboard-stdio.log');
|
|
341
345
|
const err = _openStdioLog('dashboard-stdio.log');
|
|
342
346
|
const args = [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'dashboard.js')];
|
|
@@ -444,19 +448,43 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
444
448
|
// failure rather than a false "healthy".
|
|
445
449
|
const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000, 150, dashProc.pid) || requested.port;
|
|
446
450
|
if (actualPort !== requested.port) {
|
|
447
|
-
console.
|
|
451
|
+
console.error(`\n ERROR: Dashboard bound to unexpected port ${actualPort}; required ${requested.port}.`);
|
|
452
|
+
console.error(' Refusing to verify a split-port Minions topology. Run `minions restart` again.');
|
|
453
|
+
_cleanupFailedStackSpawn({
|
|
454
|
+
enginePid: engineProc.pid,
|
|
455
|
+
dashboardPid: dashProc.pid,
|
|
456
|
+
supervisorPid: supProc.pid,
|
|
457
|
+
dashboardPort: actualPort,
|
|
458
|
+
});
|
|
459
|
+
process.exit(1);
|
|
448
460
|
}
|
|
461
|
+
const supervisorInternals = require(path.join(PKG_ROOT, 'engine', 'supervisor'))._internals;
|
|
449
462
|
const result = await waitForRestartHealth({
|
|
450
463
|
minionsHome: MINIONS_HOME,
|
|
464
|
+
expectedEnginePid: engineProc.pid,
|
|
451
465
|
dashboardPid: dashProc.pid,
|
|
452
466
|
dashboardPort: actualPort,
|
|
467
|
+
supervisorPid: supProc.pid,
|
|
453
468
|
timeoutMs: _resolveRestartHealthTimeoutMs(),
|
|
454
469
|
// Verify the process bound to the port IS the dashboard we just spawned
|
|
455
470
|
// (beacon pid match), not a stale leftover still holding it.
|
|
456
471
|
requireBeaconOwner: true,
|
|
472
|
+
requireExactTopology: true,
|
|
473
|
+
listProcessPids: supervisorInternals.listNodePidsMatching,
|
|
474
|
+
topologyScripts: {
|
|
475
|
+
engine: path.join(MINIONS_HOME, 'engine.js'),
|
|
476
|
+
dashboard: path.join(MINIONS_HOME, 'dashboard.js'),
|
|
477
|
+
supervisor: path.join(MINIONS_HOME, 'engine', 'supervisor.js'),
|
|
478
|
+
},
|
|
457
479
|
});
|
|
458
480
|
if (!result.ok) {
|
|
459
481
|
console.error(formatRestartHealthError(result));
|
|
482
|
+
_cleanupFailedStackSpawn({
|
|
483
|
+
enginePid: engineProc.pid,
|
|
484
|
+
dashboardPid: dashProc.pid,
|
|
485
|
+
supervisorPid: supProc.pid,
|
|
486
|
+
dashboardPort: actualPort,
|
|
487
|
+
});
|
|
460
488
|
process.exit(1);
|
|
461
489
|
}
|
|
462
490
|
console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);
|
|
@@ -479,6 +507,17 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
479
507
|
});
|
|
480
508
|
}
|
|
481
509
|
|
|
510
|
+
function _cleanupFailedStackSpawn({ enginePid, dashboardPid, supervisorPid, dashboardPort }) {
|
|
511
|
+
writeStopIntent('restart verification failed');
|
|
512
|
+
for (const pid of [supervisorPid, dashboardPid, enginePid]) killPidOnly(pid);
|
|
513
|
+
for (const pid of [supervisorPid, dashboardPid, enginePid]) waitForPidDeath(pid, 5000);
|
|
514
|
+
if (dashboardPort) {
|
|
515
|
+
killByPort(dashboardPort);
|
|
516
|
+
waitForPortRelease(dashboardPort, 5000);
|
|
517
|
+
}
|
|
518
|
+
shared.clearDashboardPortFile(MINIONS_HOME);
|
|
519
|
+
}
|
|
520
|
+
|
|
482
521
|
/** Poll engine/dashboard-port.json until it appears (dashboard wrote it on
|
|
483
522
|
* listen) or the timeout elapses. Returns the bound port or null on
|
|
484
523
|
* timeout. Stale files left behind by a crashed dashboard are tolerated —
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -151,8 +151,11 @@ const RENDER_VERSIONS = {
|
|
|
151
151
|
dispatch: 2,
|
|
152
152
|
engineLog: 2,
|
|
153
153
|
metrics: 1,
|
|
154
|
-
// Bumped 8
|
|
155
|
-
|
|
154
|
+
// Bumped 7→8: WI detail modal's branch pill moved out of the Artifacts
|
|
155
|
+
// chip row into its own non-interactive "Branch" field (W-mr2qjr3b0002522f).
|
|
156
|
+
// Bumped 8→9 for paused pre-dispatch evaluation guidance.
|
|
157
|
+
// Bumped 9→10: row actions now preserve work-item source scope when IDs collide.
|
|
158
|
+
workItems: 10,
|
|
156
159
|
skills: 1,
|
|
157
160
|
commands: 1,
|
|
158
161
|
mcpServers: 1,
|
|
@@ -840,6 +843,9 @@ function _processStatusUpdate(data, opts) {
|
|
|
840
843
|
.catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
|
|
841
844
|
.then(function () { try { renderKeepProcesses(); } catch {} });
|
|
842
845
|
}
|
|
846
|
+
if (typeof renderWorktreeQuarantineRefs === 'function') {
|
|
847
|
+
_safeRender('worktreeQuarantineRefs', function () { renderWorktreeQuarantineRefs(); });
|
|
848
|
+
}
|
|
843
849
|
// Work items now come from /api/work-items — a dedicated fresh-JSON
|
|
844
850
|
// endpoint that re-runs getWorkItems() server-side on every request
|
|
845
851
|
// with input-mtime ETag (issue #2949). The previous /state/ + stale-
|
|
@@ -1250,3 +1250,41 @@ async function killKeepPid(agentId, pid) {
|
|
|
1250
1250
|
}
|
|
1251
1251
|
|
|
1252
1252
|
window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid, mountKeepProcessesPanel, unmountKeepProcessesPanel };
|
|
1253
|
+
|
|
1254
|
+
async function renderWorktreeQuarantineRefs() {
|
|
1255
|
+
const root = document.getElementById('worktree-quarantine-refs-content');
|
|
1256
|
+
const count = document.getElementById('worktree-quarantine-refs-count');
|
|
1257
|
+
if (!root) return;
|
|
1258
|
+
let html;
|
|
1259
|
+
try {
|
|
1260
|
+
const res = await fetch('/api/worktree-quarantine-refs');
|
|
1261
|
+
const data = await res.json();
|
|
1262
|
+
if (!res.ok) throw new Error(data.error || String(res.status));
|
|
1263
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
1264
|
+
if (count) count.textContent = String(items.length);
|
|
1265
|
+
if (!items.length) {
|
|
1266
|
+
html = '<p class="empty">No quarantined work refs found.</p>';
|
|
1267
|
+
} else {
|
|
1268
|
+
html = '<table style="width:100%;border-collapse:collapse"><thead><tr>' +
|
|
1269
|
+
'<th style="text-align:left">Project</th><th style="text-align:left">Backup ref</th>' +
|
|
1270
|
+
'<th style="text-align:left">SHA</th><th style="text-align:left">Created</th></tr></thead><tbody>' +
|
|
1271
|
+
items.map(function (item) {
|
|
1272
|
+
if (item.error) {
|
|
1273
|
+
return '<tr><td>' + escHtml(item.project || '') + '</td><td colspan="3" style="color:var(--red)">' + escHtml(item.error) + '</td></tr>';
|
|
1274
|
+
}
|
|
1275
|
+
return '<tr><td>' + escHtml(item.project || '') + '</td>' +
|
|
1276
|
+
'<td><code>' + escHtml(item.ref || '') + '</code></td>' +
|
|
1277
|
+
'<td><code>' + escHtml(String(item.sha || '').slice(0, 12)) + '</code></td>' +
|
|
1278
|
+
'<td>' + escHtml(item.createdAt || '') + '</td></tr>';
|
|
1279
|
+
}).join('') +
|
|
1280
|
+
'</tbody></table>';
|
|
1281
|
+
}
|
|
1282
|
+
} catch (e) {
|
|
1283
|
+
if (count) count.textContent = '?';
|
|
1284
|
+
html = '<span style="color:var(--red)">Failed to load: ' + escHtml(e.message) + '</span>';
|
|
1285
|
+
}
|
|
1286
|
+
// eslint-disable-next-line no-unsanitized/method -- structural HTML only; API fields are escaped above
|
|
1287
|
+
root.replaceChildren(document.createRange().createContextualFragment(html));
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
window.MinionsWorktreeQuarantineRefs = { renderWorktreeQuarantineRefs };
|
|
@@ -52,10 +52,17 @@ function needsAttentionInfo(item) {
|
|
|
52
52
|
if (item._preDispatchEval && item._preDispatchEval.valid === false) {
|
|
53
53
|
var rawReason = String(item._preDispatchEval.reason || '(no reason recorded)');
|
|
54
54
|
var firstLine = rawReason.split(/\r?\n/, 1)[0] || rawReason;
|
|
55
|
+
var exhausted = item._preDispatchEval.exhausted === true;
|
|
56
|
+
var prefix = exhausted
|
|
57
|
+
? 'Pre-dispatch evaluation paused after ' + (item._preDispatchEval.rejectCount || '?') + ' unchanged rejections: '
|
|
58
|
+
: 'Pre-dispatch evaluation rejected: ';
|
|
59
|
+
var fullReason = exhausted
|
|
60
|
+
? rawReason + '\n\nEdit the work item description to resume evaluation, or cancel it.'
|
|
61
|
+
: rawReason;
|
|
55
62
|
return {
|
|
56
63
|
kind: 'pre_dispatch_eval',
|
|
57
|
-
short:
|
|
58
|
-
full:
|
|
64
|
+
short: prefix + firstLine,
|
|
65
|
+
full: fullReason,
|
|
59
66
|
};
|
|
60
67
|
}
|
|
61
68
|
|
|
@@ -1065,7 +1072,9 @@ function viewAgentOutput(logPath) {
|
|
|
1065
1072
|
});
|
|
1066
1073
|
}
|
|
1067
1074
|
|
|
1075
|
+
let _inboxNoteOpenSeq = 0;
|
|
1068
1076
|
function openInboxNote(filename) {
|
|
1077
|
+
var openSeq = ++_inboxNoteOpenSeq;
|
|
1069
1078
|
var idx = (inboxData || []).findIndex(function(item) { return item.name === filename; });
|
|
1070
1079
|
if (idx >= 0) {
|
|
1071
1080
|
openModal(idx);
|
|
@@ -1083,29 +1092,61 @@ function openInboxNote(filename) {
|
|
|
1083
1092
|
// 'archive:<name>' token). Fetch it from notes/archive/ and show it in the
|
|
1084
1093
|
// modal instead of bouncing to the inbox page. Falls back to the inbox page
|
|
1085
1094
|
// only if the file is genuinely gone (e.g. merged into notes.md).
|
|
1095
|
+
var title = filename + ' (archived)';
|
|
1096
|
+
var filePath = 'notes/archive/' + filename;
|
|
1097
|
+
var titleEl = document.getElementById('modal-title');
|
|
1098
|
+
var bodyEl = document.getElementById('modal-body');
|
|
1099
|
+
if (titleEl) titleEl.textContent = title;
|
|
1100
|
+
if (bodyEl) {
|
|
1101
|
+
bodyEl.innerHTML = '<p style="color:var(--muted)">Loading...</p>';
|
|
1102
|
+
bodyEl.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
1103
|
+
bodyEl.style.whiteSpace = 'normal';
|
|
1104
|
+
}
|
|
1105
|
+
_modalDocContext = { title: title, content: '', selection: '' };
|
|
1106
|
+
_modalFilePath = filePath;
|
|
1107
|
+
var qaEl = document.getElementById('modal-qa');
|
|
1108
|
+
if (qaEl) qaEl.style.display = 'none';
|
|
1109
|
+
var modalEl = document.getElementById('modal');
|
|
1110
|
+
if (modalEl) modalEl.classList.add('open');
|
|
1111
|
+
|
|
1112
|
+
// A note can be superseded by another note before its archive fetch resolves,
|
|
1113
|
+
// or by a different artifact stacked above it. Guard both cases so delayed
|
|
1114
|
+
// success and failure handlers only affect the selection that started them.
|
|
1115
|
+
var applyIfCurrent = function(fn) {
|
|
1116
|
+
if (openSeq !== _inboxNoteOpenSeq ||
|
|
1117
|
+
_modalFilePath !== filePath ||
|
|
1118
|
+
!modalEl ||
|
|
1119
|
+
!modalEl.classList.contains('open')) return false;
|
|
1120
|
+
if (typeof withTopFrame === 'function') return withTopFrame('note', filename, fn);
|
|
1121
|
+
fn();
|
|
1122
|
+
return true;
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1086
1125
|
fetch('/state/notes/archive/' + encodeURIComponent(filename))
|
|
1087
1126
|
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
|
1088
1127
|
.then(function(content) {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1128
|
+
applyIfCurrent(function() {
|
|
1129
|
+
if (!titleEl || !bodyEl) return;
|
|
1130
|
+
titleEl.textContent = title;
|
|
1131
|
+
// eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes note content before assembling HTML; filename is set via textContent
|
|
1132
|
+
bodyEl.innerHTML = '<div style="font-size:var(--text-md);line-height:1.7;color:var(--muted)">' + renderMd(content) + '</div>';
|
|
1133
|
+
// Wire Doc Chat for the deeplinked archive note, mirroring the live-inbox
|
|
1134
|
+
// openModal() path (render-prs.js). Without this the archive branch opened
|
|
1135
|
+
// the note as plain text with no Q&A panel, so deeplinking into a note
|
|
1136
|
+
// from a (completed) work item sometimes silently dropped doc-chat.
|
|
1137
|
+
_modalDocContext = { title: title, content: content, selection: '' };
|
|
1138
|
+
_modalFilePath = filePath;
|
|
1139
|
+
showModalQa();
|
|
1140
|
+
// P-34fa5d79 — Source-WI chip for archive notes. Same convention as the
|
|
1141
|
+
// live-inbox branch above; parsed off the freshly-fetched content.
|
|
1142
|
+
_wiInjectSourceChipFromFrontmatter(content, 'modal-body');
|
|
1143
|
+
});
|
|
1105
1144
|
})
|
|
1106
1145
|
.catch(function() {
|
|
1107
|
-
|
|
1108
|
-
|
|
1146
|
+
applyIfCurrent(function() {
|
|
1147
|
+
closeModal();
|
|
1148
|
+
switchPage('inbox');
|
|
1149
|
+
});
|
|
1109
1150
|
});
|
|
1110
1151
|
}
|
|
1111
1152
|
|
package/dashboard/js/settings.js
CHANGED
|
@@ -476,6 +476,7 @@ async function openSettings() {
|
|
|
476
476
|
// Tooltip on copilotDisableBuiltinMcps MUST warn about the split-brain risk
|
|
477
477
|
settingsToggle('Copilot: disable built-in MCPs', 'set-copilotDisableBuiltinMcps', e.copilotDisableBuiltinMcps !== false,
|
|
478
478
|
'⚠ When OFF, Copilot agents can autonomously create PRs/labels/comments via the github-mcp-server, bypassing pull-requests.json tracking — Minions and Copilot end up with split views of the same PR. Keep ON unless you understand the risk.') +
|
|
479
|
+
settingsToggle('Propagate CLAUDE.md to non-Claude runtimes', 'set-propagateClaudeMdForNonClaudeRuntimes', e.propagateClaudeMdForNonClaudeRuntimes !== false, 'Copilot/Codex have NO native CLAUDE.md auto-load. When ON (default), the engine bounded-discovers the nearest applicable repo-authored CLAUDE.md files for a dispatch and injects them as a "Project instructions (CLAUDE.md)" context layer so load-bearing "keep in sync" rules and blessed tooling reach the agent. Claude is always skipped (its CLI reads CLAUDE.md itself). Orthogonal to each runtime\'s native AGENTS.md discovery — CLAUDE.md carries no split-brain risk because Copilot/Codex never read it themselves. Per-project override via project.propagateClaudeMdForNonClaudeRuntimes.') +
|
|
479
480
|
settingsToggle('Copilot: reasoning summaries', 'set-copilotReasoningSummaries', !!e.copilotReasoningSummaries, '--enable-reasoning-summaries (Anthropic-family models only)') +
|
|
480
481
|
'</div>' +
|
|
481
482
|
'<div class="settings-grid-2">' +
|
|
@@ -973,19 +974,16 @@ async function loadModelsForAgent(agentId, runtimeName, currentValue) {
|
|
|
973
974
|
cell.innerHTML = '<input ' + baseAttrs + ' value="' + escHtml(currentValue || '') + '" placeholder="' + escHtml(runtimeName) + ' default" style="' + baseStyle + '">';
|
|
974
975
|
return;
|
|
975
976
|
}
|
|
976
|
-
|
|
977
|
+
const listId = 'agent-model-options-' + agentId;
|
|
978
|
+
let opts = '';
|
|
977
979
|
for (const m of models) {
|
|
978
980
|
const id = m.id || m.name || '';
|
|
979
981
|
if (!id) continue;
|
|
980
|
-
const label = m.name && m.name !== id ?
|
|
981
|
-
opts += '<option value="' + escHtml(id) + '"' + (
|
|
982
|
-
}
|
|
983
|
-
// Preserve unknown saved values so a user-set custom ID survives the next save.
|
|
984
|
-
if (currentValue && !models.some(m => (m.id || m.name) === currentValue)) {
|
|
985
|
-
opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom — invalid for ' + escHtml(runtimeName) + '?)</option>';
|
|
982
|
+
const label = m.name && m.name !== id ? m.name : '';
|
|
983
|
+
opts += '<option value="' + escHtml(id) + '"' + (label ? ' label="' + escHtml(label) + '"' : '') + '></option>';
|
|
986
984
|
}
|
|
987
985
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, model id, model label, currentValue, runtimeName)
|
|
988
|
-
cell.innerHTML = '<
|
|
986
|
+
cell.innerHTML = '<input ' + baseAttrs + ' list="' + escHtml(listId) + '" value="' + escHtml(currentValue || '') + '" placeholder="' + escHtml(runtimeName) + ' default" autocomplete="off" style="' + baseStyle + '"><datalist id="' + escHtml(listId) + '">' + opts + '</datalist>';
|
|
989
987
|
}
|
|
990
988
|
|
|
991
989
|
function settingsToggle(label, id, checked, hint) {
|
|
@@ -1191,6 +1189,7 @@ async function saveSettings() {
|
|
|
1191
1189
|
claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
|
|
1192
1190
|
copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
|
|
1193
1191
|
copilotDisableBuiltinMcps: !!document.getElementById('set-copilotDisableBuiltinMcps')?.checked,
|
|
1192
|
+
propagateClaudeMdForNonClaudeRuntimes: !!document.getElementById('set-propagateClaudeMdForNonClaudeRuntimes')?.checked,
|
|
1194
1193
|
copilotStreamMode: document.getElementById('set-copilotStreamMode')?.value || 'on',
|
|
1195
1194
|
copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
|
|
1196
1195
|
claudePreApproveWorkspaceMcps: !!document.getElementById('set-claudePreApproveWorkspaceMcps')?.checked,
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
</h2>
|
|
27
27
|
<div id="keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
|
|
28
28
|
</section>
|
|
29
|
+
<section id="worktree-quarantine-refs-section">
|
|
30
|
+
<h2>Quarantined Work <span class="count" id="worktree-quarantine-refs-count">0</span>
|
|
31
|
+
<span class="qa-section-subtitle">local backup refs available for manual recovery</span>
|
|
32
|
+
</h2>
|
|
33
|
+
<div id="worktree-quarantine-refs-content"><p class="empty">No quarantined work refs found.</p></div>
|
|
34
|
+
</section>
|
|
29
35
|
<section id="managed-processes-section">
|
|
30
36
|
<h2>Managed Processes <span class="count" id="managed-processes-count">0</span>
|
|
31
37
|
<span class="qa-section-subtitle">engine-managed long-running services</span>
|
package/dashboard.js
CHANGED
|
@@ -1655,13 +1655,23 @@ const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
|
|
|
1655
1655
|
// W-mpetru8a000s123a — /api/plans cache. Dashboard auto-polls every 4s; the
|
|
1656
1656
|
// pre-cache cold path walked 4 directories (plans/, prd/, archive variants),
|
|
1657
1657
|
// sync-stat'd every file, and parsed/regex-scanned each .md → 2-3s blocking.
|
|
1658
|
-
//
|
|
1659
|
-
// call invalidatePlansCache() for immediate visibility
|
|
1658
|
+
// 30s TTL keeps the full markdown/SQL rollup off the 4s browser poll cadence.
|
|
1659
|
+
// Mutation handlers call invalidatePlansCache() for immediate visibility;
|
|
1660
|
+
// out-of-process edits surface within 30s.
|
|
1660
1661
|
let _plansCache = null;
|
|
1661
1662
|
let _plansCacheTs = 0;
|
|
1662
|
-
const PLANS_CACHE_TTL_MS =
|
|
1663
|
+
const PLANS_CACHE_TTL_MS = 30000;
|
|
1663
1664
|
function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
|
|
1664
1665
|
|
|
1666
|
+
async function _mapInBatches(items, batchSize, mapper) {
|
|
1667
|
+
const result = [];
|
|
1668
|
+
for (let start = 0; start < items.length; start += batchSize) {
|
|
1669
|
+
result.push(...await Promise.all(items.slice(start, start + batchSize).map(mapper)));
|
|
1670
|
+
if (start + batchSize < items.length) await _yieldEventLoop();
|
|
1671
|
+
}
|
|
1672
|
+
return result;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1665
1675
|
function statePathExists(filePath) {
|
|
1666
1676
|
return fs.existsSync(filePath);
|
|
1667
1677
|
}
|
|
@@ -2961,15 +2971,35 @@ async function _freshnessEntryMtimeMs(fp, depth) {
|
|
|
2961
2971
|
return max;
|
|
2962
2972
|
}
|
|
2963
2973
|
|
|
2974
|
+
const FRESHNESS_MTIME_CACHE_TTL_MS = 1000;
|
|
2975
|
+
const _freshnessMtimeCache = new Map();
|
|
2976
|
+
|
|
2964
2977
|
async function _maxInputMtimeMs(inputs) {
|
|
2978
|
+
const key = inputs.map(fp => path.resolve(fp)).sort().join('\0');
|
|
2979
|
+
const now = Date.now();
|
|
2980
|
+
const cached = _freshnessMtimeCache.get(key);
|
|
2981
|
+
if (cached && now - cached.createdAt < FRESHNESS_MTIME_CACHE_TTL_MS) {
|
|
2982
|
+
return cached.promise;
|
|
2983
|
+
}
|
|
2965
2984
|
// Directory mtime only advances on add/remove, not in-place edits. Walk two
|
|
2966
2985
|
// levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
|
|
2967
2986
|
// Deeper steering edits still rely on add/remove cadence, matching the prior
|
|
2968
2987
|
// bounded walk.
|
|
2969
|
-
const
|
|
2988
|
+
const promise = Promise.all(
|
|
2970
2989
|
inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
|
|
2971
|
-
);
|
|
2972
|
-
|
|
2990
|
+
).then(mtimes => Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0)));
|
|
2991
|
+
_freshnessMtimeCache.set(key, { createdAt: now, promise });
|
|
2992
|
+
if (_freshnessMtimeCache.size > 100) {
|
|
2993
|
+
for (const [cacheKey, entry] of _freshnessMtimeCache) {
|
|
2994
|
+
if (now - entry.createdAt >= FRESHNESS_MTIME_CACHE_TTL_MS) _freshnessMtimeCache.delete(cacheKey);
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
try {
|
|
2998
|
+
return await promise;
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
if (_freshnessMtimeCache.get(key)?.promise === promise) _freshnessMtimeCache.delete(key);
|
|
3001
|
+
throw err;
|
|
3002
|
+
}
|
|
2973
3003
|
}
|
|
2974
3004
|
async function serveFreshJson(req, res, opts) {
|
|
2975
3005
|
const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
|
|
@@ -3161,7 +3191,20 @@ function handleStateRead(req, res) {
|
|
|
3161
3191
|
res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
|
|
3162
3192
|
try {
|
|
3163
3193
|
const stream = fs.createReadStream(resolved);
|
|
3194
|
+
// HANDLE-LEAK GUARD (#858): destroy the read stream if the client aborts or
|
|
3195
|
+
// the connection closes before the pipe drains. Without this, an aborted
|
|
3196
|
+
// GET /state/notes.md leaks the open file handle indefinitely (confirmed
|
|
3197
|
+
// via handle64: dashboard held a handle on notes.md for 2+ hours). On
|
|
3198
|
+
// Windows that lingering handle makes the engine's consolidation
|
|
3199
|
+
// renameSync throw EPERM, which used to crash-loop the whole daemon.
|
|
3200
|
+
const destroyStream = () => { if (!stream.destroyed) stream.destroy(); };
|
|
3201
|
+
if (typeof res.on === 'function') res.on('close', destroyStream);
|
|
3202
|
+
if (typeof req.on === 'function') {
|
|
3203
|
+
req.on('close', destroyStream);
|
|
3204
|
+
req.on('aborted', destroyStream);
|
|
3205
|
+
}
|
|
3164
3206
|
stream.on('error', (err) => {
|
|
3207
|
+
destroyStream();
|
|
3165
3208
|
if (!res.headersSent) {
|
|
3166
3209
|
res.statusCode = 500;
|
|
3167
3210
|
res.setHeader('Content-Type', 'application/json');
|
|
@@ -7688,7 +7731,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7688
7731
|
{ dir: path.join(PLANS_DIR, 'archive'), archived: true },
|
|
7689
7732
|
]) {
|
|
7690
7733
|
const allFiles = (await fsp.readdir(dir).catch(() => [])).filter(f => f.endsWith('.md'));
|
|
7691
|
-
const dirResults = await
|
|
7734
|
+
const dirResults = await _mapInBatches(allFiles, 25, async f => {
|
|
7692
7735
|
const filePath = path.join(dir, f);
|
|
7693
7736
|
const [content, stat] = await Promise.all([
|
|
7694
7737
|
fsp.readFile(filePath, 'utf8').catch(() => ''),
|
|
@@ -7725,10 +7768,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
7725
7768
|
revisionFeedback: null,
|
|
7726
7769
|
version: versionMatch ? parseInt(versionMatch[1]) : null,
|
|
7727
7770
|
};
|
|
7728
|
-
})
|
|
7771
|
+
});
|
|
7729
7772
|
for (const r of dirResults) if (r) plans.push(r);
|
|
7730
7773
|
}
|
|
7731
7774
|
|
|
7775
|
+
const sourcePlanMtimes = new Map();
|
|
7776
|
+
const sourcePlanNames = [...new Set(prdRows
|
|
7777
|
+
.filter(row => !row.archived && row.plan?.source_plan)
|
|
7778
|
+
.map(row => row.plan.source_plan))];
|
|
7779
|
+
const sourceStats = await _mapInBatches(sourcePlanNames, 25, async sourcePlan => {
|
|
7780
|
+
const stat = await fsp.stat(path.join(PLANS_DIR, sourcePlan)).catch(() => null);
|
|
7781
|
+
return [sourcePlan, stat ? Math.floor(stat.mtimeMs) : null];
|
|
7782
|
+
});
|
|
7783
|
+
for (const [sourcePlan, mtimeMs] of sourceStats) sourcePlanMtimes.set(sourcePlan, mtimeMs);
|
|
7784
|
+
|
|
7732
7785
|
// --- PRD JSON records (prd/*.json + prd/archive/*.json, SQL-backed) ---
|
|
7733
7786
|
for (const row of prdRows) {
|
|
7734
7787
|
const plan = row.plan;
|
|
@@ -7742,11 +7795,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
7742
7795
|
// so the Plans tab does not silently mirror a stale-but-cached false.
|
|
7743
7796
|
let freshStale = false;
|
|
7744
7797
|
if (!archived && plan.source_plan) {
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
if (recorded && sourceMtime > recorded) freshStale = true;
|
|
7749
|
-
} catch { /* source plan may have been deleted/renamed */ }
|
|
7798
|
+
const sourceMtime = sourcePlanMtimes.get(plan.source_plan);
|
|
7799
|
+
const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
|
|
7800
|
+
if (recorded && sourceMtime != null && sourceMtime > recorded) freshStale = true;
|
|
7750
7801
|
}
|
|
7751
7802
|
// P-e8d49105 — _projects rollup feeds the plan-card multi-badge
|
|
7752
7803
|
// path in dashboard/js/render-plans.js. For PRD JSONs, walk
|
|
@@ -11762,6 +11813,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11762
11813
|
age_minutes: ageMinutes,
|
|
11763
11814
|
};
|
|
11764
11815
|
}
|
|
11816
|
+
|
|
11765
11817
|
return {
|
|
11766
11818
|
agentId: rec.agentId,
|
|
11767
11819
|
valid: true,
|
|
@@ -11785,6 +11837,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11785
11837
|
}
|
|
11786
11838
|
}
|
|
11787
11839
|
|
|
11840
|
+
async function handleWorktreeQuarantineRefs(req, res) {
|
|
11841
|
+
try {
|
|
11842
|
+
const { listQuarantineRefs } = require('./engine/quarantine-refs');
|
|
11843
|
+
return jsonReply(res, 200, {
|
|
11844
|
+
items: await listQuarantineRefs(PROJECTS),
|
|
11845
|
+
generatedAt: new Date().toISOString(),
|
|
11846
|
+
}, req);
|
|
11847
|
+
} catch (e) {
|
|
11848
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
11849
|
+
}
|
|
11850
|
+
}
|
|
11851
|
+
|
|
11788
11852
|
async function handleKeepProcessesKill(req, res) {
|
|
11789
11853
|
try {
|
|
11790
11854
|
const body = await readBody(req);
|
|
@@ -13179,6 +13243,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13179
13243
|
}},
|
|
13180
13244
|
{ method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
|
|
13181
13245
|
{ method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
|
|
13246
|
+
{ method: 'GET', path: '/api/worktree-quarantine-refs', desc: 'List recoverable refs/minions/quarantine/* and quarantine-wip/* refs across configured projects', handler: handleWorktreeQuarantineRefs },
|
|
13182
13247
|
{ method: 'POST', path: '/api/keep-processes/kill', desc: 'Kill a single kept PID and remove it from the agent\'s keep-pids.json', params: 'agentId, pid', handler: handleKeepProcessesKill },
|
|
13183
13248
|
// P-4b8d2e57 (managed-spawn item 4): engine-managed long-running services.
|
|
13184
13249
|
// List/by-name return ETag + honor If-None-Match → 304. Kill removes from
|
|
@@ -14767,7 +14832,8 @@ if (require.main === module) {
|
|
|
14767
14832
|
// supervisor) discovers it instead of guessing 7331. The original
|
|
14768
14833
|
// server.on('error') one-shot exit is replaced by the retry loop — only
|
|
14769
14834
|
// non-EADDRINUSE errors abort.
|
|
14770
|
-
const
|
|
14835
|
+
const requireRequestedPort = process.env.MINIONS_REQUIRE_DASHBOARD_PORT === '1';
|
|
14836
|
+
const maxScan = requireRequestedPort ? 1 : (shared.DASHBOARD_PORT_SCAN_MAX || 100);
|
|
14771
14837
|
let listenErr = null;
|
|
14772
14838
|
for (let attempt = 0; attempt < maxScan; attempt++) {
|
|
14773
14839
|
const tryPort = REQUESTED_PORT + attempt;
|
|
@@ -14793,7 +14859,10 @@ if (require.main === module) {
|
|
|
14793
14859
|
}
|
|
14794
14860
|
if (listenErr) {
|
|
14795
14861
|
if (listenErr.code === 'EADDRINUSE') {
|
|
14796
|
-
|
|
14862
|
+
const range = requireRequestedPort
|
|
14863
|
+
? String(REQUESTED_PORT)
|
|
14864
|
+
: `[${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]`;
|
|
14865
|
+
console.error(`\n No free dashboard port at ${range}. Free it and retry.\n`);
|
|
14797
14866
|
} else {
|
|
14798
14867
|
console.error(listenErr);
|
|
14799
14868
|
}
|
package/docs/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Hands-on stories and distribution guides for people running or evaluating Minion
|
|
|
29
29
|
Architecture, design proposals, and lifecycle references for people working on the engine, dashboard, or playbooks.
|
|
30
30
|
|
|
31
31
|
- [branch-derivation.md](branch-derivation.md) — Engine-side branch fallback (`work/<wi-id>`) vs. agent-authored long form, the structured-vs-loose PR-pointer extractors, and the canonical PR-fix duplication incident.
|
|
32
|
+
- [claude-md-propagation.md](claude-md-propagation.md) — Propagating repo-authored `CLAUDE.md` instructions to runtimes that don't auto-load it (Copilot/Codex): the `claudeMdNativeDiscovery` capability flag, the bounded nearest-applicable walk-up discovery, the `propagateClaudeMdForNonClaudeRuntimes` config knob, and why it's orthogonal to each runtime's native `AGENTS.md` discovery.
|
|
32
33
|
- [command-center.md](command-center.md) — Command Center (CC) chat panel: configured-runtime sessions, resume semantics, system-prompt invalidation, and per-tab session storage.
|
|
33
34
|
- [specs/agent-rename.md](specs/agent-rename.md) — Design spec for **agent rename & name decoupling** — the prerequisite for the Role/Agent Library. Formalizes the stable opaque agent id, makes charters name-agnostic, and adds `POST /api/agents/rename` (display name + emoji). Feature-flagged (`agent-rename`) and fully backward-compatible.
|
|
34
35
|
- [specs/agent-configurability.md](specs/agent-configurability.md) — Design spec for the user-configurable Role / Agent Library, feature-flagged behind `agent-library` and fully backward-compatible. Builds on `agent-rename.md`. Role = the reusable durable charter; agent = a thin instance (identity + variable expertise + role pointer). Phase 0 renames `skills`→`expertise`; Phase 1 exposes/edits role charters + per-agent expertise; Phase 2 adds role/agent CRUD with builtin delete-protection.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# CLAUDE.md propagation to non-Claude runtimes
|
|
2
|
+
|
|
3
|
+
**Work item:** W-mrdon0pe000l045a
|
|
4
|
+
**Config flag:** `engine.propagateClaudeMdForNonClaudeRuntimes` (default `true`)
|
|
5
|
+
**Per-project override:** `project.propagateClaudeMdForNonClaudeRuntimes`
|
|
6
|
+
**Module:** `engine/claude-md-context.js` · **Injection:** `engine/playbook.js`
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
`CLAUDE.md` auto-discovery is a **Claude-Code-CLI-only** convention — the `claude`
|
|
11
|
+
binary reads repo-authored `CLAUDE.md` files itself at startup (unless `--bare`).
|
|
12
|
+
The **Copilot** and **Codex** runtimes have **no equivalent auto-load** for
|
|
13
|
+
`CLAUDE.md` at all. So repo-authored `CLAUDE.md` files — which frequently carry
|
|
14
|
+
load-bearing "must be kept in sync" instructions and name blessed tooling — are
|
|
15
|
+
structurally invisible to a Copilot/Codex-based fleet.
|
|
16
|
+
|
|
17
|
+
### Motivating incident
|
|
18
|
+
|
|
19
|
+
`W-mrdkv6zc00071fef` (a `fluidclientframework.android` dependency bump in
|
|
20
|
+
`office/src`) missed a **third** sync-point file (`settings.gradle.kts:116`) that
|
|
21
|
+
`loop/app/android/android/CLAUDE.md` explicitly warns about, and that
|
|
22
|
+
`updateDeps.bat` (named in that same `CLAUDE.md`) would have caught via its build
|
|
23
|
+
verification step. Neither the implementer nor the reviewer ever saw that file's
|
|
24
|
+
content because Copilot has no `CLAUDE.md` auto-load mechanism. Propagating
|
|
25
|
+
`CLAUDE.md` content directly addresses recurrence of this bug class.
|
|
26
|
+
|
|
27
|
+
## Relationship to Copilot's native `AGENTS.md` discovery (NOT the same gap)
|
|
28
|
+
|
|
29
|
+
Copilot has its **own native `AGENTS.md` auto-load**, which (post-#817, "Delegate
|
|
30
|
+
repository harness discovery to native runtimes") Minions leaves to the runtime
|
|
31
|
+
CLI itself rather than suppressing or synthesizing.
|
|
32
|
+
|
|
33
|
+
`CLAUDE.md` propagation is **independent and additive**:
|
|
34
|
+
|
|
35
|
+
- `CLAUDE.md` carries **zero split-brain risk** for Copilot/Codex, because those
|
|
36
|
+
runtimes **never read it themselves** — there is nothing to conflict with.
|
|
37
|
+
- Therefore propagation is **orthogonal** to Copilot's native `AGENTS.md`
|
|
38
|
+
discovery: whether or not the runtime loads its own `AGENTS.md` has **no
|
|
39
|
+
effect** on `CLAUDE.md` propagation.
|
|
40
|
+
|
|
41
|
+
| Concern | Owner | What governs it |
|
|
42
|
+
|---------|-------|-----------------|
|
|
43
|
+
| Copilot's OWN `AGENTS.md` auto-load | the Copilot CLI (native) | delegated to the runtime; Minions does not suppress or synthesize it |
|
|
44
|
+
| Repo `CLAUDE.md` invisible to non-Claude runtimes | `propagateClaudeMdForNonClaudeRuntimes` | injects a "Project instructions (CLAUDE.md)" prompt layer |
|
|
45
|
+
|
|
46
|
+
## Capability flag
|
|
47
|
+
|
|
48
|
+
Each runtime adapter (`engine/runtimes/<name>.js`) declares
|
|
49
|
+
`capabilities.claudeMdNativeDiscovery`:
|
|
50
|
+
|
|
51
|
+
| Runtime | `claudeMdNativeDiscovery` | Propagation |
|
|
52
|
+
|---------|---------------------------|-------------|
|
|
53
|
+
| `claude` | `true` | **skipped** — the Claude CLI already reads CLAUDE.md; re-injecting would double it |
|
|
54
|
+
| `copilot` | `false` | **injected** |
|
|
55
|
+
| `codex` | `false` | **injected** |
|
|
56
|
+
|
|
57
|
+
Unknown runtimes are treated as **not** auto-discovering (propagate), since an
|
|
58
|
+
unrecognized runtime is far more likely to lack native CLAUDE.md loading than to
|
|
59
|
+
have it. Engine code gates on the capability flag, never on `runtime.name`.
|
|
60
|
+
|
|
61
|
+
## Bounded discovery algorithm
|
|
62
|
+
|
|
63
|
+
`office/src` is a huge monorepo — a full tree walk collecting every `CLAUDE.md`
|
|
64
|
+
is not viable. Instead the module mirrors how a human/tool resolves "nearest
|
|
65
|
+
applicable instructions":
|
|
66
|
+
|
|
67
|
+
1. **Collect path hints** for the dispatch (`engine.js#_deriveClaudeMdPathHints`):
|
|
68
|
+
`item.meta.workdir` and `item.references[*].path`.
|
|
69
|
+
2. **Walk UP** from each hint directory toward the **project root**, `stat`-ing
|
|
70
|
+
`CLAUDE.md` at each ancestor level (`discoverClaudeMdFiles`).
|
|
71
|
+
3. **Always include the project-root `CLAUDE.md`** as a fallback — if there are
|
|
72
|
+
no path hints, at minimum the project root is checked (discovery is never
|
|
73
|
+
skipped entirely).
|
|
74
|
+
4. **Nearest-wins ordering:** deepest directory first, project root last, so the
|
|
75
|
+
most specific instructions lead.
|
|
76
|
+
5. **Bounds (cheap by construction):**
|
|
77
|
+
- `MAX_WALK_DEPTH = 24` ancestor levels per hint (deep-path guard),
|
|
78
|
+
- `MAX_FILES = 8` total collected files,
|
|
79
|
+
- path-traversal guard — hints that escape the project root are dropped,
|
|
80
|
+
- the walk **never ascends above the project root**.
|
|
81
|
+
|
|
82
|
+
This only `stat`s `CLAUDE.md` at a small number of ancestor directories, so it
|
|
83
|
+
does not meaningfully slow dispatch even in a monorepo.
|
|
84
|
+
|
|
85
|
+
## Injection & byte cap
|
|
86
|
+
|
|
87
|
+
`engine/playbook.js#renderPlaybook` adds `CLAUDE.md` propagation as a context
|
|
88
|
+
layer alongside the existing `pinned.md` → `notes.md` →
|
|
89
|
+
`knowledge/agents/<agentId>.md` layers:
|
|
90
|
+
|
|
91
|
+
- Header: `## Project instructions (CLAUDE.md)`, with a short provenance note so
|
|
92
|
+
the agent understands why it appears and that "keep in sync" rules are
|
|
93
|
+
authoritative.
|
|
94
|
+
- Content is wrapped in an `<UNTRUSTED-INPUT>` fence (repo-authored, semi-trusted).
|
|
95
|
+
- Each file is labeled by its repo-relative path (`### <relPath>`).
|
|
96
|
+
- The whole block is capped at `ENGINE_DEFAULTS.maxNotesPromptBytes` (the same
|
|
97
|
+
budget the sibling notes / agent-memory layers use), so it can never balloon a
|
|
98
|
+
prompt on a large monorepo. Individual files are truncated with a
|
|
99
|
+
"read the full file if needed" marker when they exceed the remaining budget.
|
|
100
|
+
|
|
101
|
+
Injection only happens when **all** hold:
|
|
102
|
+
|
|
103
|
+
1. the resolved runtime's `claudeMdNativeDiscovery` is falsy (Copilot/Codex),
|
|
104
|
+
2. `resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)` returns true,
|
|
105
|
+
3. the project has a `localPath`, and
|
|
106
|
+
4. at least one non-empty `CLAUDE.md` was discovered.
|
|
107
|
+
|
|
108
|
+
## Config resolution
|
|
109
|
+
|
|
110
|
+
`shared.resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)`:
|
|
111
|
+
|
|
112
|
+
1. per-project boolean `project.propagateClaudeMdForNonClaudeRuntimes`, else
|
|
113
|
+
2. fleet-wide boolean `engine.propagateClaudeMdForNonClaudeRuntimes`, else
|
|
114
|
+
3. hardcoded default `true`.
|
|
115
|
+
|
|
116
|
+
Only an explicit boolean counts as "set" at either level (undefined/null/string
|
|
117
|
+
fall through). Editable from **Settings → Copilot Tuning → "Propagate CLAUDE.md
|
|
118
|
+
to non-Claude runtimes"** or via `POST /api/settings`.
|