@yemi33/minions 0.1.1949 → 0.1.1951

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.
Files changed (40) hide show
  1. package/dashboard/js/command-center.js +9 -0
  2. package/dashboard/js/modal-qa.js +10 -0
  3. package/dashboard/js/refresh.js +4 -0
  4. package/dashboard/js/render-dispatch.js +25 -0
  5. package/dashboard/js/render-other.js +109 -2
  6. package/dashboard/js/settings.js +1 -1
  7. package/dashboard/layout.html +2 -2
  8. package/dashboard/pages/engine.html +6 -0
  9. package/dashboard/slim.html +1987 -0
  10. package/dashboard/styles.css +8 -0
  11. package/dashboard.js +450 -40
  12. package/docs/completion-reports.md +25 -0
  13. package/docs/design-state-storage.md +1 -1
  14. package/docs/slim-ux/architecture-suggestions.md +467 -0
  15. package/docs/slim-ux/concepts.md +824 -0
  16. package/engine/ado-mcp-wrapper.js +33 -7
  17. package/engine/ado.js +123 -15
  18. package/engine/cc-worker-pool.js +41 -0
  19. package/engine/cleanup.js +71 -34
  20. package/engine/cli.js +37 -0
  21. package/engine/dispatch.js +32 -9
  22. package/engine/features.js +6 -0
  23. package/engine/gh-token.js +137 -0
  24. package/engine/github.js +166 -29
  25. package/engine/issues.js +29 -0
  26. package/engine/keep-process-sweep.js +397 -0
  27. package/engine/lifecycle.js +150 -33
  28. package/engine/playbook.js +17 -0
  29. package/engine/queries.js +71 -0
  30. package/engine/recovery.js +6 -0
  31. package/engine/shared.js +481 -30
  32. package/engine/spawn-agent.js +44 -2
  33. package/engine/timeout.js +34 -11
  34. package/engine/worktree-pool.js +410 -0
  35. package/engine.js +643 -119
  36. package/package.json +6 -3
  37. package/playbooks/review.md +2 -0
  38. package/playbooks/shared-rules.md +3 -1
  39. package/prompts/cc-system.md +24 -0
  40. 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 => ({ name: p.name, path: p.localPath, description: p.description || '' })),
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
- return { answer: result.text, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
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
- return { answer: result.text, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
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: routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT),
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
- const { execSync, execFileSync } = require('child_process');
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
- // Launch PowerShell directly (not through cmd.exe) and hide its console so
5877
- // only the folder picker is visible. Closing the picker should cancel cleanly
5878
- // instead of surfacing a raw shell "Command failed" error.
5879
- const psScript = [
5880
- 'Add-Type -AssemblyName System.Windows.Forms',
5881
- '$f = New-Object System.Windows.Forms.FolderBrowserDialog',
5882
- '$f.Description = "Select project folder"',
5883
- '$f.ShowNewFolderButton = $false',
5884
- '$owner = New-Object System.Windows.Forms.Form',
5885
- '$owner.TopMost = $true',
5886
- '$owner.StartPosition = "CenterScreen"',
5887
- '$owner.WindowState = "Minimized"',
5888
- '$owner.Show()',
5889
- '$owner.Hide()',
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
- selectedPath = execFileSync('powershell.exe', [
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
- stdio: ['ignore', 'pipe', 'pipe'],
5907
- }).trim();
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
- selectedPath = execFileSync('osascript', [
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
- stdio: ['ignore', 'pipe', 'pipe'],
5926
- }).trim();
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
- selectedPath = execSync(`zenity --file-selection --directory --title="Select project folder" 2>/dev/null`, { encoding: 'utf8', timeout: 120000 }).trim();
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
- invalidateStatusCache();
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
- const prompt = _joinCcPromptParts(preamble, resumeGuard, carryover, turnHeader, body.message);
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
- const result = await shared.execAsync(`gh api "repos/${slug}/pulls/${prNum}"`, { timeout: 15000, encoding: 'utf-8' });
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
- pr.branch = prData.branch;
7749
- if (pr._branchResolutionError) delete pr._branchResolutionError;
7750
- if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
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,