@yemi33/minions 0.1.1950 → 0.1.1952
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/dashboard/js/command-center.js +13 -2
- package/dashboard/js/modal-qa.js +10 -0
- package/dashboard/js/refresh.js +4 -0
- package/dashboard/js/render-dispatch.js +25 -0
- package/dashboard/js/render-other.js +109 -2
- package/dashboard/js/settings.js +1 -1
- package/dashboard/layout.html +2 -2
- package/dashboard/pages/engine.html +6 -0
- package/dashboard/slim.html +1987 -0
- package/dashboard/styles.css +8 -0
- package/dashboard.js +450 -40
- package/docs/completion-reports.md +25 -0
- package/docs/design-state-storage.md +1 -1
- package/docs/slim-ux/architecture-suggestions.md +467 -0
- package/docs/slim-ux/concepts.md +824 -0
- package/engine/ado-mcp-wrapper.js +33 -7
- package/engine/ado.js +123 -15
- package/engine/cc-worker-pool.js +41 -0
- package/engine/cleanup.js +71 -34
- package/engine/cli.js +37 -0
- package/engine/dispatch.js +32 -9
- package/engine/features.js +6 -0
- package/engine/gh-token.js +137 -0
- package/engine/github.js +166 -29
- package/engine/issues.js +29 -0
- package/engine/keep-process-sweep.js +397 -0
- package/engine/lifecycle.js +150 -33
- package/engine/playbook.js +17 -0
- package/engine/queries.js +71 -0
- package/engine/recovery.js +6 -0
- package/engine/shared.js +446 -14
- package/engine/spawn-agent.js +44 -2
- package/engine/timeout.js +34 -11
- package/engine/worktree-pool.js +410 -0
- package/engine.js +643 -119
- package/package.json +6 -3
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +3 -1
- package/prompts/cc-system.md +24 -0
- package/engine/copilot-models.json +0 -5
package/dashboard.js
CHANGED
|
@@ -34,12 +34,20 @@ const features = require('./engine/features');
|
|
|
34
34
|
const ccWorkerPool = require('./engine/cc-worker-pool');
|
|
35
35
|
const os = require('os');
|
|
36
36
|
|
|
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;
|
|
37
|
+
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
|
|
38
38
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
39
39
|
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
40
|
-
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
40
|
+
getEngineLog, getMetrics, getKnowledgeBaseEntries, getProjectGitStatus, timeSince,
|
|
41
41
|
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
|
|
42
42
|
|
|
43
|
+
// Dev vs binary differentiation. When two dashboards run side-by-side (npm
|
|
44
|
+
// install on 7331, local checkout on 7332), the favicon and title need to
|
|
45
|
+
// differ so tabs don't blur together. Detected once at startup: only git
|
|
46
|
+
// checkouts have a .git next to dashboard.js; the npm-installed copy doesn't.
|
|
47
|
+
const IS_DEV_MODE = fs.existsSync(path.join(MINIONS_DIR, '.git'));
|
|
48
|
+
const FAVICON_EMOJI = IS_DEV_MODE ? '🚧' : '👽';
|
|
49
|
+
const TITLE_SUFFIX = IS_DEV_MODE ? ' [DEV]' : '';
|
|
50
|
+
|
|
43
51
|
// Startup size guard (#1167): fail fast with a clear error when dispatch.json /
|
|
44
52
|
// cooldowns.json have ballooned past ENGINE_DEFAULTS.maxStateFileBytes. Without
|
|
45
53
|
// this, V8 silently OOMs on JSON.parse(~1 GB) and the operator has no hint as to
|
|
@@ -892,7 +900,9 @@ function buildDashboardHtml() {
|
|
|
892
900
|
let assembled = layout
|
|
893
901
|
.replace('/* __CSS__ */', () => css)
|
|
894
902
|
.replace('<!-- __PAGES__ -->', () => pageHtml)
|
|
895
|
-
.replace('/* __JS__ */', () => `window.__MINIONS_HOME = ${JSON.stringify(os.homedir())};\n${featuresBootstrap}${jsHtml}`)
|
|
903
|
+
.replace('/* __JS__ */', () => `window.__MINIONS_HOME = ${JSON.stringify(os.homedir())};\n${featuresBootstrap}${jsHtml}`)
|
|
904
|
+
.replace(/\{\{favicon_emoji\}\}/g, FAVICON_EMOJI)
|
|
905
|
+
.replace(/\{\{title_suffix\}\}/g, TITLE_SUFFIX);
|
|
896
906
|
|
|
897
907
|
if (_isTestMode()) {
|
|
898
908
|
const label = _testBadgeLabel();
|
|
@@ -1484,7 +1494,12 @@ function getStatus() {
|
|
|
1484
1494
|
})(),
|
|
1485
1495
|
pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
|
|
1486
1496
|
pinned: (() => { try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; } })(),
|
|
1487
|
-
projects: PROJECTS.map(p => ({
|
|
1497
|
+
projects: PROJECTS.map(p => ({
|
|
1498
|
+
name: p.name,
|
|
1499
|
+
path: p.localPath,
|
|
1500
|
+
description: p.description || '',
|
|
1501
|
+
...getProjectGitStatus(p.localPath),
|
|
1502
|
+
})),
|
|
1488
1503
|
autoMode: {
|
|
1489
1504
|
approvePlans: !!CONFIG.engine?.autoApprovePlans,
|
|
1490
1505
|
decompose: CONFIG.engine?.autoDecompose !== false,
|
|
@@ -2360,6 +2375,17 @@ function flushPendingDocSessions() {
|
|
|
2360
2375
|
}
|
|
2361
2376
|
}
|
|
2362
2377
|
|
|
2378
|
+
// Peek without mutating — resolveSession deletes on mismatch, but callers
|
|
2379
|
+
// need the bit *before* the deletion to surface a user-visible notice.
|
|
2380
|
+
function docSessionWillBeInvalidated(key) {
|
|
2381
|
+
if (!key) return false;
|
|
2382
|
+
const s = docSessions.get(key);
|
|
2383
|
+
return !!(s && s._promptHash !== _docChatPromptHash);
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
const DOC_SESSION_REFRESHED_NOTICE =
|
|
2387
|
+
'_[Note: previous conversation in this doc was dropped because the system prompt changed (likely an engine update). Starting a fresh session — earlier turns are not in context.]_\n\n';
|
|
2388
|
+
|
|
2363
2389
|
// Resolve session from any store (CC global or doc-specific)
|
|
2364
2390
|
function resolveSession(store, key) {
|
|
2365
2391
|
if (store === 'cc') {
|
|
@@ -3226,6 +3252,8 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
3226
3252
|
const sessionKey = filePath || title;
|
|
3227
3253
|
const docSlice = String(document || '');
|
|
3228
3254
|
|
|
3255
|
+
const sessionWasInvalidated = !freshSession && docSessionWillBeInvalidated(sessionKey);
|
|
3256
|
+
|
|
3229
3257
|
// freshSession: true → discard any prior session for this key so the call starts clean.
|
|
3230
3258
|
// Used by one-shot generation flows (e.g. Create Plan from meeting) that must not
|
|
3231
3259
|
// bleed context from earlier conversations.
|
|
@@ -3309,7 +3337,8 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
3309
3337
|
// tools (verified by _finalizeDocChatEdit re-reading disk); state mutations
|
|
3310
3338
|
// land via direct /api/* calls (correlated by X-CC-Turn-Id, surfaced as
|
|
3311
3339
|
// chips server-side).
|
|
3312
|
-
|
|
3340
|
+
const answerText = sessionWasInvalidated ? DOC_SESSION_REFRESHED_NOTICE + result.text : result.text;
|
|
3341
|
+
return { answer: answerText, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
|
|
3313
3342
|
}
|
|
3314
3343
|
|
|
3315
3344
|
async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady, onChunk, onToolUse, onRetry, systemPrompt = DOC_CHAT_SYSTEM_PROMPT, turnId }) {
|
|
@@ -3317,6 +3346,11 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
3317
3346
|
const docSlice = String(document || '');
|
|
3318
3347
|
const streamStripper = _makeDocChatStreamStripper(onChunk);
|
|
3319
3348
|
|
|
3349
|
+
// Notice is surfaced via the final answer prepend below, not via onChunk:
|
|
3350
|
+
// modal-qa's chunk handler replaces (not appends) streamedText, so an
|
|
3351
|
+
// early-emit chunk would be wiped by the next LLM token.
|
|
3352
|
+
const sessionWasInvalidated = !freshSession && docSessionWillBeInvalidated(sessionKey);
|
|
3353
|
+
|
|
3320
3354
|
if (freshSession && sessionKey) {
|
|
3321
3355
|
docSessions.delete(sessionKey);
|
|
3322
3356
|
}
|
|
@@ -3383,7 +3417,8 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
3383
3417
|
// tools (verified by _finalizeDocChatEdit re-reading disk); state mutations
|
|
3384
3418
|
// land via direct /api/* calls (correlated by X-CC-Turn-Id, surfaced as
|
|
3385
3419
|
// chips server-side).
|
|
3386
|
-
|
|
3420
|
+
const finalAnswerText = sessionWasInvalidated ? DOC_SESSION_REFRESHED_NOTICE + result.text : result.text;
|
|
3421
|
+
return { answer: finalAnswerText, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
|
|
3387
3422
|
}
|
|
3388
3423
|
|
|
3389
3424
|
// -- POST helpers --
|
|
@@ -3437,6 +3472,119 @@ function readBody(req) {
|
|
|
3437
3472
|
});
|
|
3438
3473
|
}
|
|
3439
3474
|
|
|
3475
|
+
// Pick the best PowerShell executable for in-process dialogs. pwsh.exe
|
|
3476
|
+
// (PowerShell 7+) runs on .NET 8 which is required for
|
|
3477
|
+
// Microsoft.Win32.OpenFolderDialog (used by handleProjectsBrowse to get a
|
|
3478
|
+
// modern Explorer-style picker that honors InitialDirectory). powershell.exe
|
|
3479
|
+
// (Windows PowerShell 5.1) is .NET Framework 4.x and falls through to the
|
|
3480
|
+
// older BrowseForFolder fallback. Probes pwsh once on first call and caches
|
|
3481
|
+
// the result. _execModule override is for tests.
|
|
3482
|
+
let _cachedPwshExe = null;
|
|
3483
|
+
function _pickPwshExe(_execModule) {
|
|
3484
|
+
if (_cachedPwshExe !== null) return _cachedPwshExe;
|
|
3485
|
+
if (process.platform !== 'win32') return (_cachedPwshExe = 'pwsh');
|
|
3486
|
+
const cp = _execModule || require('child_process');
|
|
3487
|
+
try {
|
|
3488
|
+
cp.execFileSync('pwsh.exe', ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.Major'], {
|
|
3489
|
+
encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
3490
|
+
});
|
|
3491
|
+
_cachedPwshExe = 'pwsh.exe';
|
|
3492
|
+
} catch {
|
|
3493
|
+
_cachedPwshExe = 'powershell.exe';
|
|
3494
|
+
}
|
|
3495
|
+
return _cachedPwshExe;
|
|
3496
|
+
}
|
|
3497
|
+
function _resetPwshExeCache() { _cachedPwshExe = null; } // test hook
|
|
3498
|
+
|
|
3499
|
+
// PowerShell script for the modern slim-UX folder picker — Explorer-style
|
|
3500
|
+
// (Microsoft.Win32.OpenFolderDialog with native path bar + InitialDirectory)
|
|
3501
|
+
// with a Shell.Application.BrowseForFolder fallback for older PowerShell.
|
|
3502
|
+
// Both branches use a topmost owner so the dialog comes to front. Returns a
|
|
3503
|
+
// self-contained script string the dashboard writes to disk and spawns via
|
|
3504
|
+
// pwsh. Gated behind the `modernPicker` request flag — classic UX uses
|
|
3505
|
+
// _buildBrowseScriptClassic instead so its UX stays exactly as it was.
|
|
3506
|
+
function _buildBrowseScriptModern(initialPath) {
|
|
3507
|
+
const psInitial = String(initialPath || '').replace(/'/g, "''");
|
|
3508
|
+
return [
|
|
3509
|
+
"$initial = '" + psInitial + "'",
|
|
3510
|
+
'$selected = $null',
|
|
3511
|
+
'try {',
|
|
3512
|
+
' Add-Type -AssemblyName PresentationFramework -ErrorAction Stop',
|
|
3513
|
+
' Add-Type -AssemblyName WindowsBase -ErrorAction Stop',
|
|
3514
|
+
' $ownerWin = New-Object System.Windows.Window',
|
|
3515
|
+
' $ownerWin.Topmost = $true',
|
|
3516
|
+
' $ownerWin.ShowInTaskbar = $false',
|
|
3517
|
+
' $ownerWin.WindowStyle = "None"',
|
|
3518
|
+
' $ownerWin.Width = 1; $ownerWin.Height = 1',
|
|
3519
|
+
' $ownerWin.Left = -10000; $ownerWin.Top = -10000',
|
|
3520
|
+
' $ownerWin.Show()',
|
|
3521
|
+
' $f = New-Object Microsoft.Win32.OpenFolderDialog -ErrorAction Stop',
|
|
3522
|
+
' $f.Title = "Select project folder"',
|
|
3523
|
+
' if ($initial -ne "" -and (Test-Path -LiteralPath $initial -PathType Container)) {',
|
|
3524
|
+
' $f.InitialDirectory = $initial',
|
|
3525
|
+
' }',
|
|
3526
|
+
' if ($f.ShowDialog($ownerWin)) { $selected = $f.FolderName }',
|
|
3527
|
+
' $ownerWin.Close()',
|
|
3528
|
+
'} catch {',
|
|
3529
|
+
' Add-Type -AssemblyName System.Windows.Forms',
|
|
3530
|
+
' $owner = New-Object System.Windows.Forms.Form',
|
|
3531
|
+
' $owner.TopMost = $true',
|
|
3532
|
+
' $owner.StartPosition = "CenterScreen"',
|
|
3533
|
+
' $owner.WindowState = "Minimized"',
|
|
3534
|
+
' $owner.Show(); $owner.Hide()',
|
|
3535
|
+
' $shell = New-Object -ComObject Shell.Application',
|
|
3536
|
+
' $folder = $shell.BrowseForFolder($owner.Handle.ToInt32(), "Select project folder", 0x50, 0)',
|
|
3537
|
+
' if ($folder -ne $null) { $selected = $folder.Self.Path }',
|
|
3538
|
+
' $owner.Dispose()',
|
|
3539
|
+
'}',
|
|
3540
|
+
'if ($selected) { Write-Output $selected }',
|
|
3541
|
+
].join('\r\n');
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
// PowerShell script for the classic dashboard folder picker — preserves the
|
|
3545
|
+
// original WinForms FolderBrowserDialog UX (tree-only, no typing,
|
|
3546
|
+
// no InitialDirectory). Kept verbatim so the non-slim dashboard's
|
|
3547
|
+
// "Scan Projects" flow looks identical to its pre-slim-UX behavior.
|
|
3548
|
+
function _buildBrowseScriptClassic() {
|
|
3549
|
+
return [
|
|
3550
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
3551
|
+
'$f = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
3552
|
+
'$f.Description = "Select project folder"',
|
|
3553
|
+
'$f.ShowNewFolderButton = $false',
|
|
3554
|
+
'$owner = New-Object System.Windows.Forms.Form',
|
|
3555
|
+
'$owner.TopMost = $true',
|
|
3556
|
+
'$owner.StartPosition = "CenterScreen"',
|
|
3557
|
+
'$owner.WindowState = "Minimized"',
|
|
3558
|
+
'$owner.Show()',
|
|
3559
|
+
'$owner.Hide()',
|
|
3560
|
+
'if ($f.ShowDialog($owner) -eq "OK") { Write-Output $f.SelectedPath }',
|
|
3561
|
+
'$owner.Dispose()',
|
|
3562
|
+
].join('\r\n');
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
// Resolve a user-typed candidate path to the nearest existing directory by
|
|
3566
|
+
// walking up its parents. Used by handleProjectsBrowse to pre-seed the
|
|
3567
|
+
// folder picker: a typed-but-not-yet-existing path like `C:\code\new-proj`
|
|
3568
|
+
// should still open the picker at `C:\code` rather than at the user's
|
|
3569
|
+
// default. Returns '' when no usable ancestor exists. Pure helper — fsModule
|
|
3570
|
+
// override is for tests.
|
|
3571
|
+
function _resolveBrowseInitialPath(candidate, fsModule) {
|
|
3572
|
+
const fsm = fsModule || fs;
|
|
3573
|
+
if (!candidate || typeof candidate !== 'string') return '';
|
|
3574
|
+
const trimmed = candidate.trim();
|
|
3575
|
+
if (!trimmed) return '';
|
|
3576
|
+
let resolved = path.resolve(trimmed);
|
|
3577
|
+
while (resolved) {
|
|
3578
|
+
try {
|
|
3579
|
+
if (fsm.statSync(resolved).isDirectory()) return resolved;
|
|
3580
|
+
} catch { /* keep walking up */ }
|
|
3581
|
+
const parent = path.dirname(resolved);
|
|
3582
|
+
if (parent === resolved) return ''; // reached drive root with no match
|
|
3583
|
+
resolved = parent;
|
|
3584
|
+
}
|
|
3585
|
+
return '';
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3440
3588
|
const _rateLimits = new Map();
|
|
3441
3589
|
function checkRateLimit(key, maxPerMinute) {
|
|
3442
3590
|
const now = Date.now();
|
|
@@ -3781,6 +3929,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
3781
3929
|
}
|
|
3782
3930
|
|
|
3783
3931
|
let found = false;
|
|
3932
|
+
let blocked = null;
|
|
3784
3933
|
mutateJsonFileLocked(wiPath, (items) => {
|
|
3785
3934
|
if (!Array.isArray(items)) items = [];
|
|
3786
3935
|
const item = items.find(i => i.id === id);
|
|
@@ -3790,6 +3939,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
3790
3939
|
found = 'already_done';
|
|
3791
3940
|
return items;
|
|
3792
3941
|
}
|
|
3942
|
+
// Refuse to re-spawn a project-less worktree-requiring WI when no
|
|
3943
|
+
// single auto-target exists. Without this, retry re-runs the same
|
|
3944
|
+
// broken spawn (drive-root rootDir) the engine already failed on.
|
|
3945
|
+
// The user can fix this by assigning a project via /api/work-items/update
|
|
3946
|
+
// before retrying. (W-mp631ux9000x6df0)
|
|
3947
|
+
if (!resolvedTarget.project
|
|
3948
|
+
&& WORKTREE_REQUIRING_TYPES.has(item.type)
|
|
3949
|
+
&& PROJECTS.length !== 1) {
|
|
3950
|
+
blocked = {
|
|
3951
|
+
error: 'project required for type=' + item.type + ' when multiple projects are configured; assign a project via /api/work-items/update before retrying',
|
|
3952
|
+
knownProjects: PROJECTS.map(p => p.name),
|
|
3953
|
+
};
|
|
3954
|
+
return items;
|
|
3955
|
+
}
|
|
3793
3956
|
found = true;
|
|
3794
3957
|
item.status = WI_STATUS.PENDING;
|
|
3795
3958
|
item._retryCount = 0; // Reset retry counter on manual retry
|
|
@@ -3801,6 +3964,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
3801
3964
|
delete item.fanOutAgents;
|
|
3802
3965
|
return items;
|
|
3803
3966
|
});
|
|
3967
|
+
if (blocked) return jsonReply(res, 400, blocked);
|
|
3804
3968
|
if (found === 'already_done') return jsonReply(res, 409, { error: 'item already completed — use force:true to retry' });
|
|
3805
3969
|
if (!found) return jsonReply(res, 404, { error: 'item not found' });
|
|
3806
3970
|
|
|
@@ -4038,13 +4202,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
4038
4202
|
if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
|
|
4039
4203
|
if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
|
|
4040
4204
|
}
|
|
4205
|
+
// Worktree-requiring types must own a project so the engine's spawnAgent
|
|
4206
|
+
// can resolve a per-project rootDir. With no project (and no single
|
|
4207
|
+
// auto-target via defaultWhenSingle), spawn falls back to MINIONS_DIR's
|
|
4208
|
+
// parent — on Windows that can collapse to a drive root and forever-fail
|
|
4209
|
+
// assertWorktreeOutsideProject. Reject up front rather than letting a
|
|
4210
|
+
// central WI silently land and loop. (W-mp631ux9000x6df0; reproduced by
|
|
4211
|
+
// W-mp629rn3000od581 — Rebecca's spawn looped 12+ times silently.)
|
|
4212
|
+
const normalizedType = routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT);
|
|
4213
|
+
if (WORKTREE_REQUIRING_TYPES.has(normalizedType)
|
|
4214
|
+
&& PROJECTS.length !== 1
|
|
4215
|
+
&& (!body.project || !String(body.project).trim())) {
|
|
4216
|
+
return jsonReply(res, 400, {
|
|
4217
|
+
error: 'project required for type=' + normalizedType + ' when multiple projects are configured',
|
|
4218
|
+
knownProjects: PROJECTS.map(p => p.name),
|
|
4219
|
+
});
|
|
4220
|
+
}
|
|
4041
4221
|
const target = resolveWorkItemsCreateTarget(body.project);
|
|
4042
4222
|
if (target.error) return jsonReply(res, 400, { error: target.error });
|
|
4043
4223
|
const wiPath = target.wiPath;
|
|
4044
4224
|
const targetProject = target.project;
|
|
4045
4225
|
const id = 'W-' + shared.uid();
|
|
4046
4226
|
const item = {
|
|
4047
|
-
id, title: body.title.trim(), type:
|
|
4227
|
+
id, title: body.title.trim(), type: normalizedType,
|
|
4048
4228
|
priority: body.priority || 'medium', description: body.description || '',
|
|
4049
4229
|
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
|
|
4050
4230
|
};
|
|
@@ -5870,31 +6050,59 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5870
6050
|
|
|
5871
6051
|
async function handleProjectsBrowse(req, res) {
|
|
5872
6052
|
try {
|
|
5873
|
-
|
|
6053
|
+
// Async child_process helpers — the picker dialog can sit on screen
|
|
6054
|
+
// for as long as the user takes to decide. execFileSync would block
|
|
6055
|
+
// Node's event loop for the entire dialog lifetime, freezing every
|
|
6056
|
+
// other request on the dashboard (status, SSE, work-item POSTs, etc.).
|
|
6057
|
+
// util.promisify(execFile) gives us the same exit-status / signal /
|
|
6058
|
+
// stdout / stderr error shape but lets the event loop keep running.
|
|
6059
|
+
const cp = require('child_process');
|
|
6060
|
+
const { promisify } = require('util');
|
|
6061
|
+
const execFileAsync = promisify(cp.execFile);
|
|
6062
|
+
const execAsync = promisify(cp.exec);
|
|
6063
|
+
// Picker mode + optional initial directory.
|
|
6064
|
+
//
|
|
6065
|
+
// The slim UX opts into the modern Explorer-style picker (Microsoft.Win32
|
|
6066
|
+
// .OpenFolderDialog with InitialDirectory support) by sending
|
|
6067
|
+
// `modernPicker: true`. The classic dashboard's Scan Projects flow does
|
|
6068
|
+
// NOT send this flag and gets the unchanged WinForms FolderBrowserDialog
|
|
6069
|
+
// — preserving its pre-slim UX exactly. initialPath is only honored when
|
|
6070
|
+
// modernPicker is true (FolderBrowserDialog has no equivalent property).
|
|
6071
|
+
let modernPicker = false;
|
|
6072
|
+
let initialPath = '';
|
|
6073
|
+
try {
|
|
6074
|
+
const body = await readBody(req);
|
|
6075
|
+
modernPicker = !!(body && body.modernPicker === true);
|
|
6076
|
+
if (modernPicker) initialPath = _resolveBrowseInitialPath(body && body.initialPath);
|
|
6077
|
+
} catch { /* no body — defaults to classic picker, no initial path */ }
|
|
5874
6078
|
let selectedPath = '';
|
|
5875
6079
|
if (process.platform === 'win32') {
|
|
5876
|
-
//
|
|
5877
|
-
//
|
|
5878
|
-
//
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
'if ($f.ShowDialog($owner) -eq "OK") { Write-Output $f.SelectedPath }',
|
|
5891
|
-
'$owner.Dispose()',
|
|
5892
|
-
].join('\r\n');
|
|
6080
|
+
// Primary: Microsoft.Win32.OpenFolderDialog (Explorer-style, native
|
|
6081
|
+
// path bar, InitialDirectory support) — requires .NET 8 / PS 7.4+.
|
|
6082
|
+
// Fallback: Shell.Application.BrowseForFolder with BIF_EDITBOX (0x10)
|
|
6083
|
+
// and BIF_NEWDIALOGSTYLE (0x40) for typeable input on older systems.
|
|
6084
|
+
// The fallback can't honor initialPath (BrowseForFolder's 4th arg
|
|
6085
|
+
// sets the tree root, not the initial selection).
|
|
6086
|
+
//
|
|
6087
|
+
// Topmost-owner trick on the fallback: BrowseForFolder doesn't
|
|
6088
|
+
// auto-front, so we hand it the handle of a hidden topmost WinForms
|
|
6089
|
+
// form so the dialog inherits z-order priority. Same trick the
|
|
6090
|
+
// earlier FolderBrowserDialog version used.
|
|
6091
|
+
const psScript = modernPicker
|
|
6092
|
+
? _buildBrowseScriptModern(initialPath)
|
|
6093
|
+
: _buildBrowseScriptClassic();
|
|
5893
6094
|
const psPath = path.join(MINIONS_DIR, 'engine', 'tmp', '_browse.ps1');
|
|
5894
6095
|
fs.mkdirSync(path.dirname(psPath), { recursive: true });
|
|
5895
6096
|
fs.writeFileSync(psPath, psScript);
|
|
6097
|
+
// Prefer pwsh.exe (PowerShell 7+) over powershell.exe (Windows
|
|
6098
|
+
// PowerShell 5.1). The OpenFolderDialog primary path requires .NET 8 /
|
|
6099
|
+
// PS 7.4+; running under 5.1 always throws to the BrowseForFolder
|
|
6100
|
+
// fallback, which can't honor InitialDirectory — meaning a typed
|
|
6101
|
+
// initial-path hint would be silently ignored. _pickPwshExe()
|
|
6102
|
+
// probes for pwsh on PATH and caches the result.
|
|
6103
|
+
const psExe = _pickPwshExe();
|
|
5896
6104
|
try {
|
|
5897
|
-
|
|
6105
|
+
const { stdout } = await execFileAsync(psExe, [
|
|
5898
6106
|
'-STA',
|
|
5899
6107
|
'-NoProfile',
|
|
5900
6108
|
'-ExecutionPolicy', 'Bypass',
|
|
@@ -5903,27 +6111,27 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5903
6111
|
encoding: 'utf8',
|
|
5904
6112
|
timeout: 120000,
|
|
5905
6113
|
windowsHide: true,
|
|
5906
|
-
|
|
5907
|
-
|
|
6114
|
+
});
|
|
6115
|
+
selectedPath = String(stdout || '').trim();
|
|
5908
6116
|
} catch (e) {
|
|
5909
6117
|
const stdout = String(e.stdout || '').trim();
|
|
5910
6118
|
const stderr = String(e.stderr || '').trim();
|
|
5911
6119
|
const signal = String(e.signal || '').toUpperCase();
|
|
5912
|
-
const status = Number.isInteger(e.status) ? e.status : null;
|
|
6120
|
+
const status = Number.isInteger(e.code) ? e.code : (Number.isInteger(e.status) ? e.status : null);
|
|
5913
6121
|
const interrupted = signal === 'SIGINT' || signal === 'SIGBREAK' || status === 0xC000013A;
|
|
5914
6122
|
if (interrupted && !stdout && !stderr) return jsonReply(res, 200, { cancelled: true });
|
|
5915
6123
|
throw e;
|
|
5916
6124
|
} finally { try { fs.unlinkSync(psPath); } catch { /* cleanup */ } }
|
|
5917
6125
|
} else if (process.platform === 'darwin') {
|
|
5918
6126
|
try {
|
|
5919
|
-
|
|
6127
|
+
const { stdout } = await execFileAsync('osascript', [
|
|
5920
6128
|
'-e',
|
|
5921
6129
|
'POSIX path of (choose folder with prompt "Select project folder")',
|
|
5922
6130
|
], {
|
|
5923
6131
|
encoding: 'utf8',
|
|
5924
6132
|
timeout: 120000,
|
|
5925
|
-
|
|
5926
|
-
|
|
6133
|
+
});
|
|
6134
|
+
selectedPath = String(stdout || '').trim();
|
|
5927
6135
|
} catch (e) {
|
|
5928
6136
|
const stderr = String(e.stderr || '').trim();
|
|
5929
6137
|
const message = String(e.message || '').trim();
|
|
@@ -5932,7 +6140,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5932
6140
|
throw e;
|
|
5933
6141
|
}
|
|
5934
6142
|
} else {
|
|
5935
|
-
|
|
6143
|
+
const { stdout } = await execAsync(`zenity --file-selection --directory --title="Select project folder" 2>/dev/null`, { encoding: 'utf8', timeout: 120000 });
|
|
6144
|
+
selectedPath = String(stdout || '').trim();
|
|
5936
6145
|
}
|
|
5937
6146
|
if (!selectedPath) return jsonReply(res, 200, { cancelled: true });
|
|
5938
6147
|
return jsonReply(res, 200, { path: selectedPath.replace(/\\/g, '/') });
|
|
@@ -6050,7 +6259,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6050
6259
|
});
|
|
6051
6260
|
if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
|
|
6052
6261
|
reloadConfig(); // Update in-memory project list immediately
|
|
6053
|
-
|
|
6262
|
+
// includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
|
|
6263
|
+
// flushing it, /api/status keeps returning the previous project list for
|
|
6264
|
+
// up to a minute after the add. Matches handleProjectsRemove's behavior.
|
|
6265
|
+
invalidateStatusCache({ includeSlow: true });
|
|
6054
6266
|
|
|
6055
6267
|
return jsonReply(res, 200, { ok: true, name, path: target, detected });
|
|
6056
6268
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
@@ -6188,6 +6400,43 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6188
6400
|
return jsonReply(res, 200, { ok: true });
|
|
6189
6401
|
}
|
|
6190
6402
|
|
|
6403
|
+
// Trigger a process-spawn + initialize + session/new (including MCP init)
|
|
6404
|
+
// in the background so the user's first message skips the ~18-21 s Copilot
|
|
6405
|
+
// cold-spawn. Runtime-gated: a no-op (200 skipped) when the pool is off, so
|
|
6406
|
+
// the frontend can fire-and-forget without branching on runtime. Idempotent
|
|
6407
|
+
// against the pool's existing warm-reuse path — calling on a hot tab is free.
|
|
6408
|
+
async function _warmCcPool(tabId, systemPromptHash) {
|
|
6409
|
+
if (!shared.resolveCcUseWorkerPool(CONFIG.engine)) return { skipped: 'pool-disabled' };
|
|
6410
|
+
if (!tabId) throw new Error('tabId required');
|
|
6411
|
+
const result = await ccWorkerPool.warmTab({
|
|
6412
|
+
tabId,
|
|
6413
|
+
model: CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel,
|
|
6414
|
+
effort: CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort,
|
|
6415
|
+
mcpServers: (CONFIG.engine && CONFIG.engine.mcpServers) || [],
|
|
6416
|
+
systemPromptHash,
|
|
6417
|
+
});
|
|
6418
|
+
return { warmed: true, lifecycle: result.lifecycle };
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6421
|
+
async function handleCcSessionWarm(req, res) {
|
|
6422
|
+
try {
|
|
6423
|
+
const body = await readBody(req);
|
|
6424
|
+
if (!body || !body.tabId) return jsonReply(res, 400, { error: 'tabId required' });
|
|
6425
|
+
const result = await _warmCcPool(body.tabId, _ccPromptHash);
|
|
6426
|
+
return jsonReply(res, 200, result);
|
|
6427
|
+
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
6428
|
+
}
|
|
6429
|
+
|
|
6430
|
+
async function handleDocChatWarm(req, res) {
|
|
6431
|
+
try {
|
|
6432
|
+
const body = await readBody(req);
|
|
6433
|
+
const sessionKey = body && (body.filePath || body.title);
|
|
6434
|
+
if (!sessionKey) return jsonReply(res, 400, { error: 'filePath or title required' });
|
|
6435
|
+
const result = await _warmCcPool(sessionKey, _docChatPromptHash);
|
|
6436
|
+
return jsonReply(res, 200, result);
|
|
6437
|
+
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
6438
|
+
}
|
|
6439
|
+
|
|
6191
6440
|
async function handleCommandCenter(req, res) {
|
|
6192
6441
|
if (checkRateLimit('command-center', 10)) return jsonReply(res, 429, { error: 'Rate limited — max 10 requests/minute' });
|
|
6193
6442
|
let tabId;
|
|
@@ -6631,7 +6880,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6631
6880
|
const ccTurnId = 'cct-' + shared.uid();
|
|
6632
6881
|
const turnSystemPrompt = renderCcSystemPromptForTurn(ccTurnId);
|
|
6633
6882
|
const turnHeader = _ccTurnHeaderPart(ccTurnId);
|
|
6634
|
-
|
|
6883
|
+
// Slim-UX project context (W-mourbrw100024855): the slim chatbox sends a
|
|
6884
|
+
// `currentProject` hint so CC can default action.project without the
|
|
6885
|
+
// user naming it every turn. Validate against the configured project
|
|
6886
|
+
// list before injecting — body.currentProject is untrusted input.
|
|
6887
|
+
const _knownProjectNames = (PROJECTS || []).map(p => p && p.name).filter(Boolean);
|
|
6888
|
+
const _currentProject = (body.currentProject && _knownProjectNames.includes(body.currentProject))
|
|
6889
|
+
? body.currentProject
|
|
6890
|
+
: null;
|
|
6891
|
+
const projectContextPart = _currentProject
|
|
6892
|
+
? `[Project Context] The user is currently working in project "${_currentProject}". Default any action's "project" field to this project unless the user explicitly names a different one.`
|
|
6893
|
+
: '';
|
|
6894
|
+
const prompt = _joinCcPromptParts(preamble, resumeGuard, carryover, turnHeader, projectContextPart, body.message);
|
|
6635
6895
|
|
|
6636
6896
|
const { trackEngineUsage: trackUsage } = require('./engine/llm');
|
|
6637
6897
|
const streamModel = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
@@ -6662,7 +6922,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6662
6922
|
console.log(`[CC-stream] Resume failed (code=${result.code}) — retrying fresh`);
|
|
6663
6923
|
const freshPreamble = buildCCStatePreamble();
|
|
6664
6924
|
const freshCarryover = _buildTranscriptCarryover(body.transcript, { currentMessage: body.message });
|
|
6665
|
-
const freshPrompt = _joinCcPromptParts(freshPreamble, freshCarryover, turnHeader, body.message);
|
|
6925
|
+
const freshPrompt = _joinCcPromptParts(freshPreamble, freshCarryover, turnHeader, projectContextPart, body.message);
|
|
6666
6926
|
toolUses = []; // discard stale metadata from the failed resume attempt
|
|
6667
6927
|
const retryPromise = _invokeCcStream({
|
|
6668
6928
|
prompt: freshPrompt, sessionId: undefined, liveState, toolUses,
|
|
@@ -7446,6 +7706,38 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7446
7706
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
7447
7707
|
}
|
|
7448
7708
|
|
|
7709
|
+
// Slim UX surface for the experimental redesigned dashboard.
|
|
7710
|
+
// The HTML lives in dashboard/slim.html so the human can iterate on the
|
|
7711
|
+
// markup directly — we read the file from disk on each request (no in-
|
|
7712
|
+
// memory cache) so editing it and refreshing the browser shows the change
|
|
7713
|
+
// without a server restart. Gating happens in the request dispatcher: this
|
|
7714
|
+
// helper is only invoked when features.isFeatureOn('slim-ux', CONFIG) is
|
|
7715
|
+
// true at request time, so the flag toggle takes effect with no restart.
|
|
7716
|
+
async function serveSlimUx(req, res) {
|
|
7717
|
+
try {
|
|
7718
|
+
const slimPath = path.join(MINIONS_DIR, 'dashboard', 'slim.html');
|
|
7719
|
+
const html = fs.readFileSync(slimPath, 'utf8')
|
|
7720
|
+
.replace(/\{\{favicon_emoji\}\}/g, FAVICON_EMOJI)
|
|
7721
|
+
.replace(/\{\{title_suffix\}\}/g, TITLE_SUFFIX);
|
|
7722
|
+
res.statusCode = 200;
|
|
7723
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
7724
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
7725
|
+
// slim.html ships an inline <script> IIFE for the chatbox + settings dialog
|
|
7726
|
+
// and a data: SVG favicon. Override the baseline strict CSP from
|
|
7727
|
+
// buildSecurityHeaders() (which forbids inline scripts) the same way the
|
|
7728
|
+
// full SPA route does — strict CSP still applies to all /api/* responses.
|
|
7729
|
+
res.setHeader(
|
|
7730
|
+
'Content-Security-Policy',
|
|
7731
|
+
"default-src 'self'; " +
|
|
7732
|
+
"script-src 'self' 'unsafe-inline'; " +
|
|
7733
|
+
"style-src 'self' 'unsafe-inline'; " +
|
|
7734
|
+
"img-src 'self' data:; " +
|
|
7735
|
+
"connect-src 'self'"
|
|
7736
|
+
);
|
|
7737
|
+
res.end(html);
|
|
7738
|
+
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
7739
|
+
}
|
|
7740
|
+
|
|
7449
7741
|
async function handleHealth(req, res) {
|
|
7450
7742
|
// /api/health is on the restart-verification hot path (polled every 250ms
|
|
7451
7743
|
// for 15s after `minions restart`). The previous implementation called
|
|
@@ -7511,6 +7803,95 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7511
7803
|
}
|
|
7512
7804
|
}
|
|
7513
7805
|
|
|
7806
|
+
// ── keep_processes (W-mp68q6ke0010de68) ───────────────────────────────────
|
|
7807
|
+
// Surfaces the live `agents/<id>/keep-pids.json` set + per-PID kill control.
|
|
7808
|
+
// The sweep in engine.js already TTL-expires + dead-PID-prunes these files;
|
|
7809
|
+
// these endpoints expose the same view + a manual-kill escape hatch.
|
|
7810
|
+
|
|
7811
|
+
function handleKeepProcessesList(req, res) {
|
|
7812
|
+
try {
|
|
7813
|
+
const keepProcessSweep = require('./engine/keep-process-sweep');
|
|
7814
|
+
const now = Date.now();
|
|
7815
|
+
const items = keepProcessSweep.listAllKeepPidsFiles({ now }).map((rec) => {
|
|
7816
|
+
const ageMinutes = (() => {
|
|
7817
|
+
try { return Math.round((now - fs.statSync(rec.filePath).mtimeMs) / 60000); }
|
|
7818
|
+
catch { return null; }
|
|
7819
|
+
})();
|
|
7820
|
+
if (!rec.valid) {
|
|
7821
|
+
return {
|
|
7822
|
+
agentId: rec.agentId,
|
|
7823
|
+
valid: false,
|
|
7824
|
+
reason: rec.reason,
|
|
7825
|
+
filePath: rec.filePath,
|
|
7826
|
+
age_minutes: ageMinutes,
|
|
7827
|
+
};
|
|
7828
|
+
}
|
|
7829
|
+
return {
|
|
7830
|
+
agentId: rec.agentId,
|
|
7831
|
+
valid: true,
|
|
7832
|
+
wi_id: rec.value.wi_id,
|
|
7833
|
+
purpose: rec.value.purpose,
|
|
7834
|
+
cwd: rec.value.cwd,
|
|
7835
|
+
ports: rec.value.ports,
|
|
7836
|
+
pids: rec.value.pids.map((pid) => ({
|
|
7837
|
+
pid,
|
|
7838
|
+
alive: keepProcessSweep.alivePids([pid]).length > 0,
|
|
7839
|
+
})),
|
|
7840
|
+
expires_at: rec.value.expires_at,
|
|
7841
|
+
expires_in_minutes: Math.max(0, Math.round((rec.value.expiresAtMs - now) / 60000)),
|
|
7842
|
+
written_by: rec.value.written_by,
|
|
7843
|
+
age_minutes: ageMinutes,
|
|
7844
|
+
};
|
|
7845
|
+
});
|
|
7846
|
+
return jsonReply(res, 200, { items, generatedAt: new Date(now).toISOString() }, req);
|
|
7847
|
+
} catch (e) {
|
|
7848
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
7849
|
+
}
|
|
7850
|
+
}
|
|
7851
|
+
|
|
7852
|
+
async function handleKeepProcessesKill(req, res) {
|
|
7853
|
+
try {
|
|
7854
|
+
const body = await readBody(req);
|
|
7855
|
+
const agentId = String(body.agentId || '').trim();
|
|
7856
|
+
const pidNum = Number(body.pid);
|
|
7857
|
+
if (!agentId || !/^[a-z0-9_-]+$/i.test(agentId)) {
|
|
7858
|
+
return jsonReply(res, 400, { error: 'valid agentId required' }, req);
|
|
7859
|
+
}
|
|
7860
|
+
if (!Number.isInteger(pidNum) || pidNum <= 0) {
|
|
7861
|
+
return jsonReply(res, 400, { error: 'valid pid required' }, req);
|
|
7862
|
+
}
|
|
7863
|
+
const keepProcessSweep = require('./engine/keep-process-sweep');
|
|
7864
|
+
const filePath = path.join(MINIONS_DIR, 'agents', agentId, keepProcessSweep.KEEP_PIDS_FILENAME);
|
|
7865
|
+
let raw;
|
|
7866
|
+
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
|
7867
|
+
catch { return jsonReply(res, 404, { error: 'keep-pids.json not found for agent' }, req); }
|
|
7868
|
+
let parsed;
|
|
7869
|
+
try { parsed = JSON.parse(raw); }
|
|
7870
|
+
catch (e) { return jsonReply(res, 400, { error: `keep-pids.json malformed: ${e.message}` }, req); }
|
|
7871
|
+
const pidsBefore = Array.isArray(parsed.pids) ? parsed.pids.map(Number) : [];
|
|
7872
|
+
if (!pidsBefore.includes(pidNum)) {
|
|
7873
|
+
return jsonReply(res, 404, { error: `pid ${pidNum} not declared in ${filePath}` }, req);
|
|
7874
|
+
}
|
|
7875
|
+
const killed = shared.killByPidImmediate(pidNum);
|
|
7876
|
+
const remaining = pidsBefore.filter((p) => p !== pidNum);
|
|
7877
|
+
let action;
|
|
7878
|
+
if (remaining.length === 0) {
|
|
7879
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
7880
|
+
action = 'killed-and-removed-file';
|
|
7881
|
+
} else {
|
|
7882
|
+
parsed.pids = remaining;
|
|
7883
|
+
try { fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2)); } catch (e) {
|
|
7884
|
+
return jsonReply(res, 500, { error: `failed to update keep-pids.json: ${e.message}` }, req);
|
|
7885
|
+
}
|
|
7886
|
+
action = 'killed-and-updated-file';
|
|
7887
|
+
}
|
|
7888
|
+
shared.log('info', `keep-processes manual kill: agent=${agentId} pid=${pidNum} killed=${killed} action=${action}`);
|
|
7889
|
+
return jsonReply(res, 200, { ok: true, killed, action, remaining }, req);
|
|
7890
|
+
} catch (e) {
|
|
7891
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
7892
|
+
}
|
|
7893
|
+
}
|
|
7894
|
+
|
|
7514
7895
|
// ── Route Registry ──────────────────────────────────────────────────────────
|
|
7515
7896
|
// Order matters: specific routes before general ones (e.g., /api/plans/approve before /api/plans/:file)
|
|
7516
7897
|
|
|
@@ -7566,6 +7947,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7566
7947
|
_trackSseClient(_statusStreamClients, req, res);
|
|
7567
7948
|
}},
|
|
7568
7949
|
{ method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
|
|
7950
|
+
{ method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
|
|
7951
|
+
{ 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 },
|
|
7569
7952
|
{ method: 'GET', path: '/api/hot-reload', desc: 'SSE stream for dashboard hot-reload notifications', handler: (req, res) => {
|
|
7570
7953
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
7571
7954
|
res.write('data: connected\n\n');
|
|
@@ -7729,7 +8112,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7729
8112
|
const ghMatch = url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
|
|
7730
8113
|
if (ghMatch) {
|
|
7731
8114
|
const slug = ghMatch[1];
|
|
7732
|
-
|
|
8115
|
+
// P-a7c4d2e8 (F1): argv-form gh + slug validation. `slug` is built from
|
|
8116
|
+
// a regex match against untrusted PR-link input (the body of POST
|
|
8117
|
+
// /api/pull-requests/link); validate before exec. `prNum` is already
|
|
8118
|
+
// a number; coerce to string for argv.
|
|
8119
|
+
const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], { timeout: 15000 });
|
|
7733
8120
|
const d = JSON.parse(result);
|
|
7734
8121
|
prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
|
|
7735
8122
|
} else if (adoTarget && !initialPrData) {
|
|
@@ -7745,9 +8132,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7745
8132
|
if (prData.title) pr.title = prData.title.slice(0, 120);
|
|
7746
8133
|
if (prData.description) pr.description = prData.description.slice(0, 500);
|
|
7747
8134
|
if (!pr.branch && prData.branch) {
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
8135
|
+
// P-a7c4d2e8 (F3): validate API-derived branch before persistence
|
|
8136
|
+
// so it can never flow into a later git command unchecked. On
|
|
8137
|
+
// invalid ref, log and skip (defensive degrade — don't crash
|
|
8138
|
+
// the enrichment IIFE).
|
|
8139
|
+
try {
|
|
8140
|
+
pr.branch = shared.validateGitRef(prData.branch);
|
|
8141
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
8142
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
8143
|
+
} catch (e) {
|
|
8144
|
+
shared.log('warn', `PR link enrichment: invalid head.ref for ${prId}, skipping branch persistence: ${e.message}`);
|
|
8145
|
+
}
|
|
7751
8146
|
}
|
|
7752
8147
|
if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
|
|
7753
8148
|
return prs;
|
|
@@ -7901,6 +8296,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7901
8296
|
// Doc chat
|
|
7902
8297
|
{ 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 },
|
|
7903
8298
|
{ 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 },
|
|
8299
|
+
{ method: 'POST', path: '/api/doc-chat/warm', desc: 'Pre-warm the worker pool for a doc-chat session (process + MCP init, no LLM call). No-op when pool is off.', params: 'filePath? | title?', handler: handleDocChatWarm },
|
|
7904
8300
|
|
|
7905
8301
|
// Inbox
|
|
7906
8302
|
{ method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
|
|
@@ -7927,6 +8323,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7927
8323
|
{ method: 'POST', path: '/api/command-center', desc: 'Conversational command center with full minions context', params: 'message, tabId?, sessionId?, transcript?', handler: handleCommandCenter },
|
|
7928
8324
|
{ method: 'POST', path: '/api/command-center/stream', desc: 'Streaming CC — SSE with text chunks as they arrive', params: 'message, tabId?, sessionId?, transcript?', handler: handleCommandCenterStream },
|
|
7929
8325
|
{ method: 'GET', path: '/api/cc-sessions', desc: 'List CC session metadata for all tabs', handler: handleCCSessionsList },
|
|
8326
|
+
{ method: 'POST', path: '/api/cc-sessions/warm', desc: 'Pre-warm the worker pool for a CC tab (process + MCP init, no LLM call). No-op when pool is off.', params: 'tabId', handler: handleCcSessionWarm },
|
|
7930
8327
|
{ method: 'DELETE', path: /^\/api\/cc-sessions\/([\w-]+)$/, template: '/api/cc-sessions/:id', desc: 'Delete a CC session by tab ID', handler: handleCCSessionDelete },
|
|
7931
8328
|
|
|
7932
8329
|
// Schedules
|
|
@@ -8254,6 +8651,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8254
8651
|
}
|
|
8255
8652
|
}
|
|
8256
8653
|
|
|
8654
|
+
// Slim UX takeover — when the 'slim-ux' feature flag is on, the root
|
|
8655
|
+
// dashboard route serves dashboard/slim.html instead of the full SPA.
|
|
8656
|
+
// Checked at request time so /api/features/toggle flips behavior with
|
|
8657
|
+
// no restart; flag-off means zero behavior change for the catch-all.
|
|
8658
|
+
if (pathname === '/' && features.isFeatureOn('slim-ux', CONFIG)) {
|
|
8659
|
+
return serveSlimUx(req, res);
|
|
8660
|
+
}
|
|
8661
|
+
|
|
8257
8662
|
// Serve dashboard HTML with gzip + caching
|
|
8258
8663
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
8259
8664
|
res.setHeader('ETag', HTML_ETAG);
|
|
@@ -8322,6 +8727,11 @@ module.exports = {
|
|
|
8322
8727
|
_readWorkspaceMcpServers,
|
|
8323
8728
|
_dedupeMcpServers,
|
|
8324
8729
|
readBody,
|
|
8730
|
+
_resolveBrowseInitialPath,
|
|
8731
|
+
_pickPwshExe,
|
|
8732
|
+
_resetPwshExeCache,
|
|
8733
|
+
_buildBrowseScriptModern,
|
|
8734
|
+
_buildBrowseScriptClassic,
|
|
8325
8735
|
_filterCcTabSessions,
|
|
8326
8736
|
_getVersionCheckInterval,
|
|
8327
8737
|
_normalizeMeetingParticipants: normalizeMeetingParticipants,
|