@yemi33/minions 0.1.2077 → 0.1.2079

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.
@@ -511,8 +511,17 @@ let _lastStatusOkAt = Date.now();
511
511
  let _consecutiveStatusFails = 0;
512
512
  let _unreachableSince = 0; // 0 = currently reachable
513
513
  let _unreachableAgeTimer = null;
514
- const _UNREACHABLE_FAIL_THRESHOLD = 2;
515
- const _UNREACHABLE_AGE_MS = 12000;
514
+ // 3 + 20s (was 2 + 12s) — the prior thresholds tripped a banner on a single
515
+ // safeFetch abort (timeout 15s in state.js) because the OR'd age side was
516
+ // already satisfied. With safeFetch=15s, the age budget needs >15s to avoid
517
+ // the trip-on-first-slow-response footgun. 3 consecutive fails ≈ 12s of real
518
+ // outage at the 4s poll cadence, which still surfaces the banner promptly
519
+ // during a real dashboard crash but tolerates one slow rebuild + retry.
520
+ const _UNREACHABLE_FAIL_THRESHOLD = 3;
521
+ const _UNREACHABLE_AGE_MS = 20000;
522
+ // Once the banner is up we throttle polls (exponential, capped at 30s) to
523
+ // avoid hammering a struggling dashboard / network. Reset on recovery.
524
+ let _nextPollAllowedAt = 0;
516
525
 
517
526
  function _formatAge(ms) {
518
527
  if (ms < 1000) return 'just now';
@@ -595,10 +604,26 @@ window._resetDashboardUnreachableForTest = function() {
595
604
  _lastStatusOkAt = Date.now();
596
605
  _consecutiveStatusFails = 0;
597
606
  _unreachableSince = 0;
607
+ _nextPollAllowedAt = 0;
598
608
  if (_unreachableAgeTimer) { clearInterval(_unreachableAgeTimer); _unreachableAgeTimer = null; }
599
609
  delete window._dashboardUnreachable;
600
610
  };
601
611
 
612
+ // Visibility wake-up: Chromium throttles setInterval on hidden tabs to ~1/min,
613
+ // so _lastStatusOkAt can drift far past the age threshold while the tab is
614
+ // backgrounded. When the user refocuses, state.js fires refresh() (line 206)
615
+ // — without this reset the age side of the OR is already satisfied and a
616
+ // single transient post-wake failure (DNS not yet up after suspend, mid-
617
+ // restart server) trips the banner before any real evidence of trouble.
618
+ // Reset BEFORE state.js calls refresh() — listener order is fine because
619
+ // both fire on the same event; this listener registered first runs first.
620
+ document.addEventListener('visibilitychange', function() {
621
+ if (document.visibilityState === 'visible' && !_unreachableSince) {
622
+ _lastStatusOkAt = Date.now();
623
+ _consecutiveStatusFails = 0;
624
+ }
625
+ });
626
+
602
627
  // ── Refresh diagnostics (W-mphejzx100081972) ─────────────────────────────
603
628
  // Ring buffer capturing the last 50 /api/status poll cycles so a user
604
629
  // reporting "the dashboard didn't auto-update when X changed" can paste
@@ -649,6 +674,11 @@ document.addEventListener('visibilitychange', function() {
649
674
 
650
675
  async function refresh() {
651
676
  if (_refreshInFlight) return;
677
+ // Backoff gate — only active while the unreachable banner is up. Skips
678
+ // setInterval ticks until _nextPollAllowedAt is reached so a downed
679
+ // dashboard isn't hammered at the steady 4s cadence (which produces
680
+ // console-spam and adds load to whatever's wedged).
681
+ if (_nextPollAllowedAt && Date.now() < _nextPollAllowedAt) return;
652
682
  _refreshInFlight = true;
653
683
  const _diagOn = _isRefreshDiagOn();
654
684
  const _t0 = _diagOn ? Date.now() : 0;
@@ -730,6 +760,7 @@ async function refresh() {
730
760
  // instead of just dismissing the banner.
731
761
  _lastStatusOkAt = Date.now();
732
762
  _consecutiveStatusFails = 0;
763
+ _nextPollAllowedAt = 0;
733
764
  if (_unreachableSince) _markDashboardReachable();
734
765
  const _renderStart = _diagOn ? Date.now() : 0;
735
766
  let _diagChanges = null;
@@ -762,6 +793,14 @@ async function refresh() {
762
793
  if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD || ageMs > _UNREACHABLE_AGE_MS) {
763
794
  _markDashboardUnreachable(e);
764
795
  }
796
+ // Backoff: once we've tripped the banner, throttle subsequent polls
797
+ // (4s → 8s → 16s → 30s cap). Reset to 0 in the success path below so
798
+ // recovery snaps back to the steady 4s cadence on the next tick.
799
+ if (_unreachableSince) {
800
+ const failsSinceTrip = Math.max(1, _consecutiveStatusFails - _UNREACHABLE_FAIL_THRESHOLD + 1);
801
+ const backoffMs = Math.min(30000, 4000 * Math.pow(2, failsSinceTrip - 1));
802
+ _nextPollAllowedAt = Date.now() + backoffMs;
803
+ }
765
804
  }
766
805
  finally {
767
806
  _refreshInFlight = false;
package/dashboard.js CHANGED
@@ -2810,9 +2810,15 @@ function _ccTurnEntryToActionType(kind) {
2810
2810
  }
2811
2811
  }
2812
2812
 
2813
- // Hash the system prompt so we can detect changes and invalidate stale sessions
2814
- const _ccPromptHash = require('crypto').createHash('md5').update(CC_SYSTEM_PROMPT_RAW).digest('hex').slice(0, 8);
2815
- const _docChatPromptHash = require('crypto').createHash('md5').update(DOC_CHAT_SYSTEM_PROMPT_RAW).digest('hex').slice(0, 8);
2813
+ // Hash the system prompt so we can detect changes and invalidate stale sessions.
2814
+ // Hashing the RENDERED prompt (not the raw template) means changes to the
2815
+ // substituted content most importantly the dynamic {{available_skills}}
2816
+ // registry built from ~/.copilot/skills/ + installed-plugins — also invalidate
2817
+ // pooled sessions on dashboard restart. Without this, installing or removing
2818
+ // a skill would leave warm sessions glued to the old registry until they aged
2819
+ // out naturally.
2820
+ const _ccPromptHash = require('crypto').createHash('md5').update(CC_STATIC_SYSTEM_PROMPT).digest('hex').slice(0, 8);
2821
+ const _docChatPromptHash = require('crypto').createHash('md5').update(DOC_CHAT_SYSTEM_PROMPT).digest('hex').slice(0, 8);
2816
2822
 
2817
2823
  function _sessionExpired(lastActiveAt, ttlMs) {
2818
2824
  if (!lastActiveAt || !ttlMs) return false;
package/engine/shared.js CHANGED
@@ -3455,6 +3455,106 @@ function describeCcProtectedPaths(liveRoot) {
3455
3455
  return `READ ONLY in the live checkout at \`${norm}\` — never write/edit: ${basenames}, ${globs}, ${prefixes}. This rule is path-scoped, not basename-scoped. Files with the same basename inside an isolated agent worktree (e.g. \`{worktreeRoot}/W-<id>/dashboard.js\`) are NOT protected — agents working in their own worktrees may edit any repository source the work item requires.`;
3456
3456
  }
3457
3457
 
3458
+ // Enumerate Copilot CLI skills from the conventional install layout so the CC
3459
+ // system prompt can mirror the vanilla runtime's <available_skills> registry.
3460
+ //
3461
+ // The CC system prompt fully replaces Copilot CLI's default — which means the
3462
+ // default's auto-injected <available_skills> block disappears, and the agent
3463
+ // has no idea what skills it has. It then falls back to grep/glob against
3464
+ // well-known paths (typically `.claude/skills/` because the underlying model
3465
+ // is Claude and that path dominates its training data). This helper rebuilds
3466
+ // the registry from the actual Copilot install dirs so the prompt can tell
3467
+ // the agent the truth.
3468
+ //
3469
+ // Locations scanned (matches Copilot CLI's discovery rules):
3470
+ // - ~/.copilot/skills/<name>/SKILL.md → location="user"
3471
+ // - ~/.copilot/installed-plugins/<mkt>/<plugin>/skills/<name>/SKILL.md
3472
+ // → location="plugin"
3473
+ //
3474
+ // Tolerates lowercase `skill.md` (Linux-style installs) and missing dirs
3475
+ // (returns an empty array rather than throwing). User-level entries shadow
3476
+ // plugin entries with the same name (mirrors Copilot CLI's user-wins rule).
3477
+ function listCopilotSkills(opts) {
3478
+ const homeDir = (opts && typeof opts.homeDir === 'string') ? opts.homeDir : os.homedir();
3479
+ const copilotRoot = path.join(homeDir, '.copilot');
3480
+ const collected = [];
3481
+
3482
+ const readSkillDir = (dir, fallbackName, location) => {
3483
+ let content;
3484
+ try {
3485
+ content = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8');
3486
+ } catch {
3487
+ try { content = fs.readFileSync(path.join(dir, 'skill.md'), 'utf8'); }
3488
+ catch { return null; }
3489
+ }
3490
+ const fm = parseSkillFrontmatter(content, fallbackName + '.md');
3491
+ return {
3492
+ name: fm.name || fallbackName,
3493
+ description: fm.description || '',
3494
+ location,
3495
+ };
3496
+ };
3497
+
3498
+ const safeReadDirents = (dir) => {
3499
+ try { return fs.readdirSync(dir, { withFileTypes: true }); }
3500
+ catch { return []; }
3501
+ };
3502
+
3503
+ // User-level skills
3504
+ for (const entry of safeReadDirents(path.join(copilotRoot, 'skills'))) {
3505
+ if (!entry.isDirectory()) continue;
3506
+ const skill = readSkillDir(path.join(copilotRoot, 'skills', entry.name), entry.name, 'user');
3507
+ if (skill) collected.push(skill);
3508
+ }
3509
+
3510
+ // Plugin-provided skills (two levels deep: marketplace/plugin)
3511
+ const pluginsRoot = path.join(copilotRoot, 'installed-plugins');
3512
+ for (const mkt of safeReadDirents(pluginsRoot)) {
3513
+ if (!mkt.isDirectory()) continue;
3514
+ for (const plugin of safeReadDirents(path.join(pluginsRoot, mkt.name))) {
3515
+ if (!plugin.isDirectory()) continue;
3516
+ const pluginSkillsDir = path.join(pluginsRoot, mkt.name, plugin.name, 'skills');
3517
+ for (const entry of safeReadDirents(pluginSkillsDir)) {
3518
+ if (!entry.isDirectory()) continue;
3519
+ const skill = readSkillDir(path.join(pluginSkillsDir, entry.name), entry.name, 'plugin');
3520
+ if (skill) collected.push(skill);
3521
+ }
3522
+ }
3523
+ }
3524
+
3525
+ // De-dupe by name, user wins over plugin (matches Copilot CLI's precedence)
3526
+ const seen = new Map();
3527
+ for (const s of collected) {
3528
+ const existing = seen.get(s.name);
3529
+ if (!existing || (s.location === 'user' && existing.location !== 'user')) {
3530
+ seen.set(s.name, s);
3531
+ }
3532
+ }
3533
+ return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
3534
+ }
3535
+
3536
+ function _xmlEscapeForPrompt(s) {
3537
+ return String(s == null ? '' : s)
3538
+ .replace(/&/g, '&amp;')
3539
+ .replace(/</g, '&lt;')
3540
+ .replace(/>/g, '&gt;');
3541
+ }
3542
+
3543
+ // Render a list of skills as an <available_skills> XML block matching the
3544
+ // shape Copilot CLI's default system prompt injects. When `skills` is empty
3545
+ // we still emit the block (with a sentinel comment) so the agent learns the
3546
+ // path exists rather than silently inferring nothing's there.
3547
+ function buildAvailableSkillsBlock(skills) {
3548
+ const list = Array.isArray(skills) ? skills : [];
3549
+ if (list.length === 0) {
3550
+ return '<available_skills>\n <!-- No skills found in ~/.copilot/skills/ or ~/.copilot/installed-plugins/<marketplace>/<plugin>/skills/ -->\n</available_skills>';
3551
+ }
3552
+ const items = list.map(s =>
3553
+ `<skill>\n <name>${_xmlEscapeForPrompt(s.name)}</name>\n <description>${_xmlEscapeForPrompt(s.description)}</description>\n <location>${_xmlEscapeForPrompt(s.location)}</location>\n</skill>`
3554
+ ).join('\n');
3555
+ return `<available_skills>\n${items}\n</available_skills>`;
3556
+ }
3557
+
3458
3558
  function renderCcSystemPrompt(raw, opts) {
3459
3559
  const liveRoot = (opts && typeof opts.liveRoot === 'string') ? opts.liveRoot : MINIONS_DIR;
3460
3560
  const turnId = (opts && typeof opts.turnId === 'string') ? opts.turnId : '';
@@ -3467,6 +3567,14 @@ function renderCcSystemPrompt(raw, opts) {
3467
3567
  if (out.includes('{{cc_protected_paths}}')) {
3468
3568
  out = out.replace(/\{\{cc_protected_paths\}\}/g, describeCcProtectedPaths(liveRoot));
3469
3569
  }
3570
+ // Available-skills enumeration walks two directory trees and reads each
3571
+ // SKILL.md's frontmatter — only do it when the template asks for it.
3572
+ // Tests pass `opts.skillsHomeDir` to redirect the scan at a fixture dir.
3573
+ if (out.includes('{{available_skills}}')) {
3574
+ const skillsOpts = (opts && typeof opts.skillsHomeDir === 'string')
3575
+ ? { homeDir: opts.skillsHomeDir } : undefined;
3576
+ out = out.replace(/\{\{available_skills\}\}/g, buildAvailableSkillsBlock(listCopilotSkills(skillsOpts)));
3577
+ }
3470
3578
  return out
3471
3579
  .replace(/\{\{cc_turn_id\}\}/g, turnId)
3472
3580
  .replace(/\{\{dashboard_port\}\}/g, dashboardPort);
@@ -5418,6 +5526,8 @@ module.exports = {
5418
5526
  isLiveCommandCenterPath,
5419
5527
  describeCcProtectedPaths,
5420
5528
  renderCcSystemPrompt,
5529
+ listCopilotSkills,
5530
+ buildAvailableSkillsBlock,
5421
5531
  _CC_PROTECTED_BASENAMES, // exported for testing
5422
5532
  _CC_PROTECTED_FILE_GLOBS, // exported for testing
5423
5533
  _CC_PROTECTED_PREFIXES, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2077",
3
+ "version": "0.1.2079",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
@@ -39,6 +39,19 @@ CAN modify: notes, plans, knowledge, work items, pull-requests.json, routing.md,
39
39
  ## Filesystem
40
40
  Minions state lives in `{{minions_dir}}/`. Key paths: `config.json` (config), `routing.md` (dispatch rules), `projects/{name}/work-items.json` & `pull-requests.json` (per-project), `agents/{id}/` (charters, output), `plans/` & `prd/` (plans), `knowledge/` (KB), `notes/inbox/` (inbox), `engine/dispatch.json` (queue), `playbooks/` (templates). Use tools to read specifics.
41
41
 
42
+ ## Your skills
43
+
44
+ Your runtime is the **Copilot CLI**, not Claude Code. Your installed skills live in:
45
+
46
+ - `~/.copilot/skills/<name>/SKILL.md` — user-level skills (yours)
47
+ - `~/.copilot/installed-plugins/<marketplace>/<plugin>/skills/<name>/SKILL.md` — plugin-provided skills (also yours)
48
+
49
+ To invoke a skill, call your `skill` tool with the skill name (the directory name). The skill body then guides the next steps.
50
+
51
+ When asked "what skills do you have", answer from the registry below — do NOT grep/glob `.claude/skills/`. Those belong to a different runtime (Claude Code) and are not available to you, even if the directory exists in cwd or `$HOME`.
52
+
53
+ {{available_skills}}
54
+
42
55
  ## Role: Orchestrator
43
56
  You are primarily a dispatcher. Agents have full Claude Code + worktrees + MCP tools and are better suited for real work — but you are not hard-stopped from handling small requests yourself.
44
57