@ijfw/memory-server 1.4.1 → 1.4.4

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.
@@ -593,6 +593,31 @@ tr:hover td{background:var(--surface)}
593
593
  </div>
594
594
  </div>
595
595
 
596
+ <!-- Sub-section: Charts (B19) -->
597
+ <div class="card">
598
+ <div class="ctitle">Audit Charts</div>
599
+ <div id="ext-charts-content">
600
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px">
601
+ <div>
602
+ <div style="color:var(--fg-dim);margin-bottom:4px">Events / hour (24h)</div>
603
+ <canvas id="ext-chart-hourly" width="320" height="80" style="width:100%;height:80px;display:block"></canvas>
604
+ </div>
605
+ <div>
606
+ <div style="color:var(--fg-dim);margin-bottom:4px">Deny rate by extension</div>
607
+ <canvas id="ext-chart-byext" width="320" height="80" style="width:100%;height:80px;display:block"></canvas>
608
+ </div>
609
+ <div>
610
+ <div style="color:var(--fg-dim);margin-bottom:4px">Top denied tools</div>
611
+ <canvas id="ext-chart-bytool" width="320" height="120" style="width:100%;height:120px;display:block"></canvas>
612
+ </div>
613
+ <div>
614
+ <div style="color:var(--fg-dim);margin-bottom:4px">Quota usage</div>
615
+ <div id="ext-chart-quotas" style="display:flex;flex-direction:column;gap:6px"></div>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ </div>
620
+
596
621
  <!-- Sub-section: Permission events -->
597
622
  <div class="card">
598
623
  <div class="ctitle" style="justify-content:space-between">
@@ -1377,7 +1402,18 @@ async function loadExtensionActive() {
1377
1402
  permsEl.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
1378
1403
  var reads = (a.permissions.reads || []).join(', ') || 'none';
1379
1404
  var writes = (a.permissions.writes || []).join(', ') || 'none';
1380
- permsEl.innerHTML = '<b style="color:var(--fg)">reads:</b> ' + reads + ' &nbsp; <b style="color:var(--fg)">writes:</b> ' + writes;
1405
+ var bReads = document.createElement('b');
1406
+ bReads.setAttribute('style', 'color:var(--fg)');
1407
+ bReads.textContent = 'reads:';
1408
+ var textReads = document.createTextNode(' ' + reads + '\u00A0\u00A0');
1409
+ var bWrites = document.createElement('b');
1410
+ bWrites.setAttribute('style', 'color:var(--fg)');
1411
+ bWrites.textContent = 'writes:';
1412
+ var textWrites = document.createTextNode(' ' + writes);
1413
+ permsEl.appendChild(bReads);
1414
+ permsEl.appendChild(textReads);
1415
+ permsEl.appendChild(bWrites);
1416
+ permsEl.appendChild(textWrites);
1381
1417
  wrap.appendChild(permsEl);
1382
1418
  }
1383
1419
  el.appendChild(wrap);
@@ -1526,7 +1562,183 @@ document.addEventListener('DOMContentLoaded', function() {
1526
1562
  loadExtensions();
1527
1563
  loadExtensionActive();
1528
1564
  loadExtensionEvents();
1565
+ loadExtensionCharts();
1529
1566
  });
1567
+
1568
+ // ====== AUDIT CHARTS (B19) ======
1569
+ // Inlined to keep dashboard a single self-contained HTML file.
1570
+
1571
+ function _ijfwChartTheme(el) {
1572
+ var fg = '#9ad2ff', bg = 'rgba(154,210,255,0.18)', warn = '#ff9b3a', txt = '#cfd6dd';
1573
+ try {
1574
+ var cs = getComputedStyle(el);
1575
+ fg = (cs.getPropertyValue('--ijfw-chart-fg') || '').trim() || fg;
1576
+ bg = (cs.getPropertyValue('--ijfw-chart-bg') || '').trim() || bg;
1577
+ warn = (cs.getPropertyValue('--ijfw-chart-warning') || '').trim() || warn;
1578
+ txt = (cs.getPropertyValue('--ijfw-chart-text') || '').trim() || txt;
1579
+ } catch (e) {}
1580
+ return { fg: fg, bg: bg, warn: warn, text: txt };
1581
+ }
1582
+
1583
+ function _ijfwLineChart(canvas, points) {
1584
+ if (!canvas || !canvas.getContext) return;
1585
+ var ctx = canvas.getContext('2d');
1586
+ var W = canvas.width, H = canvas.height;
1587
+ var theme = _ijfwChartTheme(canvas);
1588
+ ctx.clearRect(0, 0, W, H);
1589
+ if (!points || !points.length) return;
1590
+ var xs = points.map(function(p){ return p.x; });
1591
+ var ys = points.map(function(p){ return Math.max(0, p.y); });
1592
+ var xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
1593
+ if (xMax === xMin) xMax = xMin + 1;
1594
+ var yMax = Math.max(1, Math.max.apply(null, ys));
1595
+ var pad = 4, innerW = W - pad*2, innerH = H - pad*2;
1596
+ function px(x){ return pad + ((x - xMin)/(xMax - xMin)) * innerW; }
1597
+ function py(y){ return pad + (1 - (y / yMax)) * innerH; }
1598
+ ctx.beginPath(); ctx.moveTo(px(points[0].x), H - pad);
1599
+ for (var i = 0; i < points.length; i++) ctx.lineTo(px(points[i].x), py(points[i].y));
1600
+ ctx.lineTo(px(points[points.length-1].x), H - pad);
1601
+ ctx.closePath(); ctx.fillStyle = theme.bg; ctx.fill();
1602
+ ctx.beginPath(); ctx.moveTo(px(points[0].x), py(points[0].y));
1603
+ for (var j = 1; j < points.length; j++) ctx.lineTo(px(points[j].x), py(points[j].y));
1604
+ ctx.strokeStyle = theme.fg; ctx.lineWidth = 1.5; ctx.stroke();
1605
+ }
1606
+
1607
+ function _ijfwBarChart(canvas, bars, horizontal) {
1608
+ if (!canvas || !canvas.getContext) return;
1609
+ var ctx = canvas.getContext('2d');
1610
+ var W = canvas.width, H = canvas.height;
1611
+ var theme = _ijfwChartTheme(canvas);
1612
+ ctx.clearRect(0, 0, W, H);
1613
+ if (!bars || !bars.length) return;
1614
+ var values = bars.map(function(b){ return Math.max(0, b.value || 0); });
1615
+ var maxVal = Math.max(1, Math.max.apply(null, values));
1616
+ var pad = 4;
1617
+ var labelGutter = horizontal ? 80 : 14;
1618
+ var innerW = W - pad*2 - (horizontal ? labelGutter : 0);
1619
+ var innerH = H - pad*2 - (horizontal ? 0 : labelGutter);
1620
+ var slot = (horizontal ? innerH : innerW) / bars.length;
1621
+ var barW = Math.max(1, slot * 0.7);
1622
+ for (var i = 0; i < bars.length; i++) {
1623
+ var v = values[i];
1624
+ var color = bars[i].color || theme.fg;
1625
+ if (horizontal) {
1626
+ var y = pad + i*slot + (slot-barW)/2;
1627
+ var len = (v/maxVal) * innerW;
1628
+ ctx.fillStyle = theme.bg; ctx.fillRect(pad + labelGutter, y, innerW, barW);
1629
+ ctx.fillStyle = color; ctx.fillRect(pad + labelGutter, y, len, barW);
1630
+ ctx.fillStyle = theme.text; ctx.fillText(String(bars[i].label || ''), pad, y + barW*0.75);
1631
+ } else {
1632
+ var x = pad + i*slot + (slot-barW)/2;
1633
+ var len2 = (v/maxVal) * innerH;
1634
+ ctx.fillStyle = theme.bg; ctx.fillRect(x, pad, barW, innerH);
1635
+ ctx.fillStyle = color; ctx.fillRect(x, pad + (innerH - len2), barW, len2);
1636
+ ctx.fillStyle = theme.text; ctx.fillText(String(bars[i].label || '').slice(0, 8), x, H - pad);
1637
+ }
1638
+ }
1639
+ }
1640
+
1641
+ function _ijfwProgressBar(parent, data) {
1642
+ if (!parent) return;
1643
+ var theme = _ijfwChartTheme(parent);
1644
+ var cur = Math.max(0, data.current || 0);
1645
+ var lim = (data.limit === null || data.limit === undefined) ? null : data.limit;
1646
+ var label = data.label || '';
1647
+ var warn = Boolean(data.warning);
1648
+ var row = document.createElement('div');
1649
+ row.className = 'ijfw-progress' + (warn ? ' ijfw-progress--warn' : '');
1650
+ row.setAttribute('style', 'display:flex;align-items:center;gap:6px;font-size:11px');
1651
+ var lbl = document.createElement('span'); lbl.textContent = label; lbl.setAttribute('style','min-width:88px;color:var(--fg-dim)');
1652
+ row.appendChild(lbl);
1653
+ if (lim === null) {
1654
+ var val = document.createElement('span'); val.textContent = cur + ' / unlimited';
1655
+ val.setAttribute('style','color:var(--fg-dim)');
1656
+ row.appendChild(val);
1657
+ } else {
1658
+ var rail = document.createElement('span');
1659
+ rail.setAttribute('style', 'background:' + theme.bg + ';display:inline-block;height:6px;width:120px;border-radius:3px;overflow:hidden;vertical-align:middle');
1660
+ var fill = document.createElement('span');
1661
+ var pct = lim > 0 ? Math.min(100, (cur/lim)*100) : 0;
1662
+ fill.setAttribute('style', 'display:block;height:100%;width:' + pct.toFixed(1) + '%;background:' + (warn ? theme.warn : theme.fg));
1663
+ rail.appendChild(fill);
1664
+ row.appendChild(rail);
1665
+ var val2 = document.createElement('span'); val2.textContent = cur + ' / ' + lim;
1666
+ row.appendChild(val2);
1667
+ }
1668
+ parent.appendChild(row);
1669
+ }
1670
+
1671
+ function loadExtensionCharts() {
1672
+ // Hourly line chart.
1673
+ fetch('/api/extensions/aggregates?window=24h&kind=hourly')
1674
+ .then(function(r){ return r.json(); })
1675
+ .then(function(o){
1676
+ var canvas = document.getElementById('ext-chart-hourly');
1677
+ if (!canvas) return;
1678
+ var buckets = (o && o.buckets) || [];
1679
+ var points = buckets.map(function(b, i){ return { x: i, y: b.count }; });
1680
+ _ijfwLineChart(canvas, points);
1681
+ }).catch(function(){});
1682
+
1683
+ // Deny rate per extension (horizontal bar).
1684
+ fetch('/api/extensions/aggregates?window=24h&kind=by_ext')
1685
+ .then(function(r){ return r.json(); })
1686
+ .then(function(o){
1687
+ var canvas = document.getElementById('ext-chart-byext');
1688
+ if (!canvas) return;
1689
+ var rows = (o && o.rows) || [];
1690
+ var bars = rows.slice(0, 8).map(function(r){
1691
+ var total = (r.allowed||0) + (r.denied||0);
1692
+ return { label: r.ext, value: total > 0 ? (r.denied/total) * 100 : 0 };
1693
+ });
1694
+ _ijfwBarChart(canvas, bars, true);
1695
+ }).catch(function(){});
1696
+
1697
+ // Top denied tools.
1698
+ fetch('/api/extensions/aggregates?window=24h&kind=by_tool')
1699
+ .then(function(r){ return r.json(); })
1700
+ .then(function(o){
1701
+ var canvas = document.getElementById('ext-chart-bytool');
1702
+ if (!canvas) return;
1703
+ var rows = (o && o.rows) || [];
1704
+ var bars = rows.map(function(r){ return { label: r.tool, value: r.count }; });
1705
+ _ijfwBarChart(canvas, bars, true);
1706
+ }).catch(function(){});
1707
+
1708
+ // Quotas: per-extension progress bars + bash-bypass warning chip.
1709
+ fetch('/api/extensions/aggregates?window=24h&kind=quotas')
1710
+ .then(function(r){ return r.json(); })
1711
+ .then(function(o){
1712
+ var parent = document.getElementById('ext-chart-quotas');
1713
+ if (!parent) return;
1714
+ while (parent.firstChild) parent.removeChild(parent.firstChild);
1715
+ var rows = (o && o.rows) || [];
1716
+ if (rows.length === 0) {
1717
+ var empty = document.createElement('div');
1718
+ empty.setAttribute('style','color:var(--fg-dim);font-size:12px');
1719
+ empty.textContent = 'No active extension.';
1720
+ parent.appendChild(empty);
1721
+ return;
1722
+ }
1723
+ rows.forEach(function(row){
1724
+ var header = document.createElement('div');
1725
+ header.setAttribute('style','font-weight:600;font-size:12px;margin-top:4px');
1726
+ header.textContent = row.ext_name + ' (' + (row.scope || 'user') + ')';
1727
+ parent.appendChild(header);
1728
+ if (row.warn_bash_bypass) {
1729
+ var chip = document.createElement('div');
1730
+ chip.className = 'ijfw-warn-chip';
1731
+ chip.setAttribute('style','background:rgba(255,155,58,0.12);color:var(--ijfw-chart-warning,#ff9b3a);font-size:11px;padding:4px 8px;border-radius:4px;margin:4px 0');
1732
+ chip.textContent = '⚠ This extension has tool:bash and a strict files_written quota. Bash content bypasses per-file accounting.';
1733
+ parent.appendChild(chip);
1734
+ }
1735
+ var dims = row.dimensions || {};
1736
+ _ijfwProgressBar(parent, { current: (dims.files_written||{}).current||0, limit: (dims.files_written||{}).limit, label: 'files_written', warning: row.warn_bash_bypass });
1737
+ _ijfwProgressBar(parent, { current: (dims.bytes_written||{}).current||0, limit: (dims.bytes_written||{}).limit, label: 'bytes_written', warning: row.warn_bash_bypass });
1738
+ _ijfwProgressBar(parent, { current: (dims.wall_clock_ms||{}).current||0, limit: (dims.wall_clock_ms||{}).limit, label: 'wall_clock_ms' });
1739
+ });
1740
+ }).catch(function(){});
1741
+ }
1530
1742
  </script>
1531
1743
  </body>
1532
1744
  </html>
@@ -22,6 +22,8 @@ import { searchMemory } from './memory/search.js';
22
22
  import { buildRecallCounts, mergeRecallCounts, topRecalled } from './memory/recall-counter.js';
23
23
  import { PLACEHOLDER_HTML } from './design-companion.js';
24
24
  import { listExtensions } from './extension-installer.js';
25
+ import { aggregateEvents, computeWarnBashBypass, readActiveManifest } from './dashboard-aggregator.js';
26
+ import { getQuotaUsage } from './extension-quota-tracker.js';
25
27
 
26
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
29
  // REPO_ROOT: IJFW_PROJECT_ROOT override > user's interactive shell cwd (PWD) > process.cwd() fallback.
@@ -432,6 +434,85 @@ export async function startServer(options = {}) {
432
434
  }));
433
435
  }],
434
436
 
437
+ // v1.4.4 N8 — Planning doc viewer. Same path-traversal guard as /api/memory/file,
438
+ // but allowed roots are .planning/, .ijfw/memory/, and .ijfw/wave-*/ all under REPO_ROOT.
439
+ ['/api/planning', (req, res, url) => {
440
+ const rawPath = url.searchParams.get('path') || '';
441
+ if (!rawPath) {
442
+ res.writeHead(400, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ error: 'path query param required' }));
444
+ return;
445
+ }
446
+ if (isAbsolute(rawPath) || rawPath.split(/[\\/]/).some((seg) => seg === '..')) {
447
+ res.writeHead(400, { 'Content-Type': 'application/json' });
448
+ res.end(JSON.stringify({ error: 'path traversal not allowed' }));
449
+ return;
450
+ }
451
+ const reqPath = resolve(REPO_ROOT, rawPath);
452
+ function canonOrNull(p) {
453
+ try { return realpathSync(p); } catch { return null; }
454
+ }
455
+ function isUnder(allowedRoot, canonChild) {
456
+ const canonRoot = canonOrNull(allowedRoot);
457
+ if (!canonRoot || !canonChild) return false;
458
+ const rel = relative(canonRoot, canonChild);
459
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
460
+ }
461
+ function isUnderWaveRoot(canonChild) {
462
+ // r13-M-05: restrict to STATE.md / SUMMARY.md filenames within
463
+ // .ijfw/wave-*/ subdirs. Previously allowed ANY file in a wave dir;
464
+ // wave directories may contain .tmp, lock files, or partial blackboard
465
+ // data that shouldn't be browser-readable.
466
+ const ijfwDir = canonOrNull(join(REPO_ROOT, '.ijfw'));
467
+ if (!ijfwDir || !canonChild) return false;
468
+ const rel = relative(ijfwDir, canonChild);
469
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false;
470
+ const first = rel.split(/[\\/]/)[0];
471
+ if (!first.startsWith('wave-') || first.length === 'wave-'.length) return false;
472
+ return rel.endsWith('STATE.md') || rel.endsWith('SUMMARY.md');
473
+ }
474
+ const canonPath = canonOrNull(reqPath);
475
+ if (!canonPath) {
476
+ res.writeHead(404, { 'Content-Type': 'application/json' });
477
+ res.end(JSON.stringify({ error: 'file not found' }));
478
+ return;
479
+ }
480
+ const allowed = (
481
+ isUnder(join(REPO_ROOT, '.planning'), canonPath) ||
482
+ isUnder(join(REPO_ROOT, '.ijfw', 'memory'), canonPath) ||
483
+ isUnderWaveRoot(canonPath)
484
+ );
485
+ if (!allowed) {
486
+ res.writeHead(403, { 'Content-Type': 'application/json' });
487
+ res.end(JSON.stringify({ error: 'outside allowed planning roots' }));
488
+ return;
489
+ }
490
+ try {
491
+ const body = readFileSync(canonPath, 'utf8');
492
+ res.writeHead(200, { 'Content-Type': 'application/json' });
493
+ res.end(JSON.stringify({ body: body.slice(0, 200000), path: rawPath }));
494
+ } catch (err) {
495
+ res.writeHead(500, { 'Content-Type': 'application/json' });
496
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/planning' }));
497
+ }
498
+ }],
499
+
500
+ // v1.4.4 N8 — Planning-docs viewer (HTML SPA).
501
+ ['/planning', async (req, res) => {
502
+ try {
503
+ const html = await readFile(join(__dirname, 'dashboard-client-planning.html'), 'utf8');
504
+ res.writeHead(200, {
505
+ 'Content-Type': 'text/html; charset=utf-8',
506
+ 'Cache-Control': 'no-store',
507
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'",
508
+ });
509
+ res.end(html);
510
+ } catch (err) {
511
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
512
+ res.end('Planning viewer not found: ' + err.message);
513
+ }
514
+ }],
515
+
435
516
  ['/api/memory/file', (req, res, url) => {
436
517
  const rawPath = url.searchParams.get('path') || '';
437
518
  if (!rawPath) {
@@ -931,6 +1012,111 @@ export async function startServer(options = {}) {
931
1012
  res.end(JSON.stringify(events));
932
1013
  }],
933
1014
 
1015
+ // ---------- extensions: aggregates (B19) ----------
1016
+ // Server-side aggregation of permission-events.jsonl for the per-tool
1017
+ // audit charts. Filters are strictly allowlisted.
1018
+ // ?window=24h|30m|7d (regex: \d+[hmd])
1019
+ // ?kind=hourly|by_ext|by_tool|quotas
1020
+ ['/api/extensions/aggregates', async (req, res, url) => {
1021
+ const ALLOWED_KINDS = new Set(['hourly', 'by_ext', 'by_tool', 'quotas']);
1022
+ const WINDOW_RE = /^\d+(h|m|d)$/;
1023
+ const ALLOWED_KEYS = new Set(['window', 'kind']);
1024
+ try {
1025
+ for (const key of url.searchParams.keys()) {
1026
+ if (!ALLOWED_KEYS.has(key)) {
1027
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1028
+ res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
1029
+ return;
1030
+ }
1031
+ }
1032
+ const kind = url.searchParams.get('kind') || 'hourly';
1033
+ if (!ALLOWED_KINDS.has(kind)) {
1034
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1035
+ res.end(JSON.stringify({ error: `invalid kind: ${kind}` }));
1036
+ return;
1037
+ }
1038
+ const rawWindow = url.searchParams.get('window') || '24h';
1039
+ if (!WINDOW_RE.test(rawWindow)) {
1040
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1041
+ res.end(JSON.stringify({ error: `invalid window: ${rawWindow}` }));
1042
+ return;
1043
+ }
1044
+ // Parse window into ms.
1045
+ const num = parseInt(rawWindow.slice(0, -1), 10);
1046
+ const unit = rawWindow.slice(-1);
1047
+ const mult = unit === 'h' ? 3600_000 : unit === 'm' ? 60_000 : 86_400_000;
1048
+ const windowMs = num * mult;
1049
+
1050
+ const home = homedir();
1051
+ const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
1052
+
1053
+ if (kind === 'quotas') {
1054
+ // Walk the active extension state and compute per-extension usage.
1055
+ let active = null;
1056
+ try {
1057
+ const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
1058
+ if (existsSync(activePath)) {
1059
+ active = JSON.parse(readFileSync(activePath, 'utf8'));
1060
+ }
1061
+ } catch { active = null; }
1062
+ const rows = [];
1063
+ if (active && typeof active === 'object') {
1064
+ // active may be a single record or a map keyed by name.
1065
+ const entries = Array.isArray(active.extensions)
1066
+ ? active.extensions
1067
+ : (active.name ? [active] : []);
1068
+ for (const ent of entries) {
1069
+ if (!ent || !ent.name) continue;
1070
+ const scope = ent.scope || 'user';
1071
+ const manifest = readActiveManifest({ scope, name: ent.name, home, projectRoot: REPO_ROOT });
1072
+ const quotas = (manifest && manifest.quotas) || {};
1073
+ const usage = await getQuotaUsage(ent.name, { homeDir: home, limits: quotas });
1074
+ rows.push({
1075
+ ...usage,
1076
+ scope,
1077
+ warn_bash_bypass: computeWarnBashBypass(manifest),
1078
+ });
1079
+ }
1080
+ }
1081
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1082
+ res.end(JSON.stringify({ rows }));
1083
+ return;
1084
+ }
1085
+
1086
+ const agg = await aggregateEvents(eventsPath, { windowMs });
1087
+
1088
+ if (kind === 'hourly') {
1089
+ const buckets = Object.entries(agg.hourly)
1090
+ .map(([hour, count]) => ({ hour, count }))
1091
+ .sort((a, b) => a.hour.localeCompare(b.hour));
1092
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1093
+ res.end(JSON.stringify({ buckets }));
1094
+ return;
1095
+ }
1096
+
1097
+ if (kind === 'by_ext') {
1098
+ const rows = Object.entries(agg.by_extension)
1099
+ .map(([ext, v]) => ({ ext, allowed: v.allowed, denied: v.denied }))
1100
+ .sort((a, b) => b.denied - a.denied);
1101
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1102
+ res.end(JSON.stringify({ rows }));
1103
+ return;
1104
+ }
1105
+
1106
+ // kind === 'by_tool'
1107
+ const rows = Object.entries(agg.by_tool_denied)
1108
+ .map(([tool, count]) => ({ tool, count }))
1109
+ .sort((a, b) => b.count - a.count)
1110
+ .slice(0, 10);
1111
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1112
+ res.end(JSON.stringify({ rows }));
1113
+ } catch (err) {
1114
+ process.stderr.write(`[ijfw-mcp] /api/extensions/aggregates: ${err && err.message ? err.message : err}\n`);
1115
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1116
+ res.end(JSON.stringify({ buckets: [], rows: [], error: 'aggregation failed' }));
1117
+ }
1118
+ }],
1119
+
934
1120
  // ---------- extensions health (W3/t15) ----------
935
1121
  // Reads .ijfw/state/extension-registry.json (project) plus org/user via
936
1122
  // listExtensions(). Missing or malformed registry yields {extensions: []}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dispatch/active-cli.js — IJFW v1.4.3 W9-B (B18)
3
+ *
4
+ * Frozen CLI module contract:
5
+ * export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
6
+ * export const subcommandHelp — { '<subcommand>': 'one-line description' }
7
+ *
8
+ * Subcommands:
9
+ * active --check — report current active extension + last-writer IDE + divergence
10
+ * activate <name> [--ide <id>] [--strict-ide]
11
+ * — activate <name> stamping the host IDE id; refuse
12
+ * activation when --strict-ide is set AND a different
13
+ * IDE is the current writer of stale state.
14
+ *
15
+ * Phase D wires these into `dispatch/extension.js`'s main switch by iterating
16
+ * Object.entries(handlers). Until then, this module is callable in isolation.
17
+ */
18
+
19
+ import { readFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+
23
+ import {
24
+ findInstalledManifest,
25
+ writeActiveExtension,
26
+ detectCrossIdeDivergence,
27
+ } from '../active-extension-writer.js';
28
+ import { detectIde } from '../ide-detect.js';
29
+
30
+ function homeFromCtx(ctx) {
31
+ if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
32
+ if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
33
+ return undefined;
34
+ }
35
+
36
+ function projectRootFromCtx(ctx) {
37
+ if (ctx && typeof ctx.projectRoot === 'string') return ctx.projectRoot;
38
+ return process.cwd();
39
+ }
40
+
41
+ function parseArgs(args) {
42
+ // Accept either a token array (preferred per registry-cli contract) or a
43
+ // raw string (fallback for direct callers).
44
+ let tokens;
45
+ if (Array.isArray(args)) {
46
+ tokens = args.slice();
47
+ } else if (typeof args === 'string') {
48
+ tokens = args.split(/\s+/).filter(Boolean);
49
+ } else {
50
+ tokens = [];
51
+ }
52
+ const flags = { check: false, strictIde: false, ide: null };
53
+ const positional = [];
54
+ for (let i = 0; i < tokens.length; i++) {
55
+ const t = tokens[i];
56
+ if (t === '--check') { flags.check = true; continue; }
57
+ if (t === '--strict-ide') { flags.strictIde = true; continue; }
58
+ if (t === '--ide' && tokens[i + 1]) {
59
+ flags.ide = tokens[i + 1];
60
+ i++;
61
+ continue;
62
+ }
63
+ positional.push(t);
64
+ }
65
+ return { positional, flags };
66
+ }
67
+
68
+ async function activeHandler(args, ctx) {
69
+ const { flags } = parseArgs(args);
70
+ const home = homeFromCtx(ctx) || process.env.HOME || homedir();
71
+ if (!flags.check) {
72
+ return { ok: false, error: "active: --check required (usage: active --check)" };
73
+ }
74
+ // Read current active.json (best-effort).
75
+ const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
76
+ let active = null;
77
+ try {
78
+ const raw = await readFile(activePath, 'utf8');
79
+ active = JSON.parse(raw);
80
+ } catch {
81
+ // null
82
+ }
83
+ const verdict = await detectCrossIdeDivergence({ homeDir: home });
84
+ const out = {
85
+ active: active ? {
86
+ name: active.name ?? null,
87
+ scope: active.scope ?? null,
88
+ activated_at: active.activated_at ?? null,
89
+ activated_by_ide: active.activated_by_ide ?? null,
90
+ activated_by_pid: active.activated_by_pid ?? null,
91
+ } : null,
92
+ current_ide: verdict.current_ide,
93
+ divergent: !!verdict.divergent,
94
+ last_writer: verdict.last_writer ?? null,
95
+ age_seconds: verdict.age_seconds ?? null,
96
+ };
97
+ return { ok: true, output: JSON.stringify(out, null, 2) };
98
+ }
99
+
100
+ async function activateHandler(args, ctx) {
101
+ const { positional, flags } = parseArgs(args);
102
+ const name = positional[0];
103
+ if (!name || typeof name !== 'string') {
104
+ return { ok: false, error: 'activate: extension name required (usage: activate <name> [--ide <id>] [--strict-ide])' };
105
+ }
106
+ const home = homeFromCtx(ctx) || process.env.HOME || homedir();
107
+ const projectRoot = projectRootFromCtx(ctx);
108
+
109
+ const ideId = flags.ide && /^[a-z0-9-]+$/.test(flags.ide) ? flags.ide : detectIde();
110
+
111
+ // Strict-IDE gate: if active.json was last touched by a different IDE AND
112
+ // divergence semantic flags it, refuse before writing.
113
+ if (flags.strictIde) {
114
+ const verdict = await detectCrossIdeDivergence({ homeDir: home, currentIde: ideId });
115
+ if (verdict && verdict.divergent) {
116
+ return {
117
+ ok: false,
118
+ error: `[ijfw] activate refused: --strict-ide and active extension last activated by '${verdict.last_writer}'`,
119
+ };
120
+ }
121
+ }
122
+
123
+ const lookup = await findInstalledManifest(name, projectRoot, { homeDir: home });
124
+ if (!lookup.ok) return { ok: false, error: lookup.error };
125
+ const result = await writeActiveExtension(lookup.manifest, lookup.scope, { homeDir: home, ideId });
126
+ if (!result.ok) return { ok: false, error: result.error };
127
+ return {
128
+ ok: true,
129
+ output: JSON.stringify({ name, scope: lookup.scope, activated_by_ide: ideId, path: result.path }, null, 2),
130
+ };
131
+ }
132
+
133
+ export const handlers = Object.freeze({
134
+ 'active': activeHandler,
135
+ 'activate': activateHandler,
136
+ });
137
+
138
+ export const subcommandHelp = Object.freeze({
139
+ 'active': 'active --check — report current active extension + cross-IDE divergence status',
140
+ 'activate': 'activate <name> [--ide <id>] [--strict-ide] — activate extension and stamp host IDE',
141
+ });
@@ -606,8 +606,48 @@ async function cmdDeactivate() {
606
606
  }
607
607
  }
608
608
 
609
+ // v1.4.3 Phase D — CLI module registry. Each module exports the frozen
610
+ // { handlers, subcommandHelp } shape; we union their handlers into a single
611
+ // lookup and consult it BEFORE the legacy switch, so new --ide / --backend /
612
+ // quota / federation subcommands take precedence.
613
+ let _v143Handlers = null;
614
+ async function loadV143Handlers() {
615
+ if (_v143Handlers !== null) return _v143Handlers;
616
+ const [registry, signer, quota, active, wave] = await Promise.all([
617
+ import('./registry-cli.js'),
618
+ import('./signer-cli.js'),
619
+ import('./quota-cli.js'),
620
+ import('./active-cli.js'),
621
+ import('./wave-cli.js'), // v1.4.4 N9 — wave-status / wave-list
622
+ ]);
623
+ _v143Handlers = Object.assign(
624
+ Object.create(null),
625
+ registry.handlers || {},
626
+ signer.handlers || {},
627
+ quota.handlers || {},
628
+ active.handlers || {},
629
+ wave.handlers || {},
630
+ );
631
+ return _v143Handlers;
632
+ }
633
+
609
634
  export async function extensionDispatch({ command, args = '', projectRoot }) {
610
635
  const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
636
+
637
+ // v1.4.3 Phase D — CLI modules take precedence over legacy switch.
638
+ const handlers = await loadV143Handlers();
639
+ if (typeof handlers[command] === 'function') {
640
+ try {
641
+ const r = await handlers[command](ctx.args, ctx);
642
+ if (r && r.ok === false) {
643
+ return { ok: false, command, error: r.error || 'unknown error' };
644
+ }
645
+ return { ok: true, command, result: r };
646
+ } catch (err) {
647
+ return { ok: false, command, error: err && err.message ? err.message : String(err) };
648
+ }
649
+ }
650
+
611
651
  switch (command) {
612
652
  case 'add': return cmdAdd(ctx);
613
653
  case 'list': return cmdList(ctx);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * dispatch/quota-cli.js — IJFW v1.4.3 W9-A3 (B16)
3
+ *
4
+ * Frozen CLI module contract:
5
+ * export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
6
+ * export const subcommandHelp — { '<subcommand>': 'one-line description' }
7
+ *
8
+ * Phase D wires these into `dispatch/extension.js`'s main switch by iterating
9
+ * Object.entries(handlers). Until then, this module is callable in isolation.
10
+ */
11
+
12
+ import { getQuotaUsage, resetExtensionQuotas } from '../extension-quota-tracker.js';
13
+
14
+ function homeFromCtx(ctx) {
15
+ if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
16
+ if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
17
+ return undefined; // tracker will fall back to env HOME / homedir()
18
+ }
19
+
20
+ export const handlers = Object.freeze({
21
+ 'quota-status': async (args, ctx) => {
22
+ const name = Array.isArray(args) ? args[0] : undefined;
23
+ if (!name || typeof name !== 'string') {
24
+ return { ok: false, error: 'quota-status: extension name required' };
25
+ }
26
+ const usage = await getQuotaUsage(name, { homeDir: homeFromCtx(ctx) });
27
+ return { ok: true, output: JSON.stringify(usage, null, 2) };
28
+ },
29
+ 'quota-reset': async (args, ctx) => {
30
+ const name = Array.isArray(args) ? args[0] : undefined;
31
+ if (!name || typeof name !== 'string') {
32
+ return { ok: false, error: 'quota-reset: extension name required' };
33
+ }
34
+ await resetExtensionQuotas(name, { homeDir: homeFromCtx(ctx) });
35
+ return { ok: true, output: 'reset' };
36
+ },
37
+ });
38
+
39
+ export const subcommandHelp = Object.freeze({
40
+ 'quota-status': 'quota-status [<ext-name>] — print current usage vs limits',
41
+ 'quota-reset': 'quota-reset <ext-name> — admin: manually reset counters',
42
+ });