@monoes/monomindcli 2.7.6 → 2.7.8

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 (129) hide show
  1. package/.claude/agents/core/coder.md +9 -0
  2. package/.claude/agents/core/coordinator.md +62 -0
  3. package/.claude/agents/core/planner.md +9 -0
  4. package/.claude/agents/core/reviewer.md +9 -0
  5. package/.claude/agents/core/tester.md +8 -0
  6. package/.claude/helpers/handlers/gates-handler.cjs +180 -47
  7. package/.claude/helpers/handlers/route-handler.cjs +32 -3
  8. package/.claude/helpers/hook-handler.cjs +55 -3
  9. package/.claude/helpers/intelligence.cjs +3 -1
  10. package/.claude/helpers/statusline.cjs +27 -3
  11. package/.claude/helpers/utils/monograph.cjs +104 -18
  12. package/.claude/settings.json +1 -1
  13. package/.claude/skills/monodesign/scripts/live-server.mjs +38 -1
  14. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  15. package/dist/src/browser/dashboard/server.js +6 -1
  16. package/dist/src/browser/dashboard/server.js.map +1 -1
  17. package/dist/src/capabilities/index.d.ts +0 -1
  18. package/dist/src/capabilities/index.d.ts.map +1 -1
  19. package/dist/src/capabilities/index.js +8 -1
  20. package/dist/src/capabilities/index.js.map +1 -1
  21. package/dist/src/commands/agent-lifecycle.d.ts.map +1 -1
  22. package/dist/src/commands/agent-lifecycle.js +5 -1
  23. package/dist/src/commands/agent-lifecycle.js.map +1 -1
  24. package/dist/src/commands/agent-ops.d.ts.map +1 -1
  25. package/dist/src/commands/agent-ops.js +32 -2
  26. package/dist/src/commands/agent-ops.js.map +1 -1
  27. package/dist/src/commands/browse-workflow.d.ts.map +1 -1
  28. package/dist/src/commands/browse-workflow.js +30 -0
  29. package/dist/src/commands/browse-workflow.js.map +1 -1
  30. package/dist/src/commands/doctor-project-checks.d.ts.map +1 -1
  31. package/dist/src/commands/doctor-project-checks.js +8 -1
  32. package/dist/src/commands/doctor-project-checks.js.map +1 -1
  33. package/dist/src/commands/guidance.d.ts.map +1 -1
  34. package/dist/src/commands/guidance.js +8 -2
  35. package/dist/src/commands/guidance.js.map +1 -1
  36. package/dist/src/commands/hooks-extended-commands.d.ts.map +1 -1
  37. package/dist/src/commands/hooks-extended-commands.js +10 -2
  38. package/dist/src/commands/hooks-extended-commands.js.map +1 -1
  39. package/dist/src/commands/init.d.ts.map +1 -1
  40. package/dist/src/commands/init.js +18 -4
  41. package/dist/src/commands/init.js.map +1 -1
  42. package/dist/src/commands/memory-crud.d.ts.map +1 -1
  43. package/dist/src/commands/memory-crud.js +40 -3
  44. package/dist/src/commands/memory-crud.js.map +1 -1
  45. package/dist/src/commands/org-observe.d.ts.map +1 -1
  46. package/dist/src/commands/org-observe.js +67 -13
  47. package/dist/src/commands/org-observe.js.map +1 -1
  48. package/dist/src/commands/org.d.ts +11 -0
  49. package/dist/src/commands/org.d.ts.map +1 -1
  50. package/dist/src/commands/org.js +162 -15
  51. package/dist/src/commands/org.js.map +1 -1
  52. package/dist/src/commands/performance.d.ts.map +1 -1
  53. package/dist/src/commands/performance.js +31 -5
  54. package/dist/src/commands/performance.js.map +1 -1
  55. package/dist/src/commands/security-misc.d.ts.map +1 -1
  56. package/dist/src/commands/security-misc.js +18 -3
  57. package/dist/src/commands/security-misc.js.map +1 -1
  58. package/dist/src/commands/security-scan.d.ts +30 -1
  59. package/dist/src/commands/security-scan.d.ts.map +1 -1
  60. package/dist/src/commands/security-scan.js +182 -69
  61. package/dist/src/commands/security-scan.js.map +1 -1
  62. package/dist/src/commands/swarm.d.ts.map +1 -1
  63. package/dist/src/commands/swarm.js +41 -14
  64. package/dist/src/commands/swarm.js.map +1 -1
  65. package/dist/src/consensus/audit-writer.d.ts.map +1 -1
  66. package/dist/src/consensus/audit-writer.js +11 -10
  67. package/dist/src/consensus/audit-writer.js.map +1 -1
  68. package/dist/src/init/executor.d.ts.map +1 -1
  69. package/dist/src/init/executor.js +138 -22
  70. package/dist/src/init/executor.js.map +1 -1
  71. package/dist/src/init/settings-generator.d.ts.map +1 -1
  72. package/dist/src/init/settings-generator.js +4 -1
  73. package/dist/src/init/settings-generator.js.map +1 -1
  74. package/dist/src/knowledge/document-pipeline.d.ts +17 -0
  75. package/dist/src/knowledge/document-pipeline.d.ts.map +1 -1
  76. package/dist/src/knowledge/document-pipeline.js +62 -3
  77. package/dist/src/knowledge/document-pipeline.js.map +1 -1
  78. package/dist/src/mcp-client.d.ts.map +1 -1
  79. package/dist/src/mcp-client.js +78 -0
  80. package/dist/src/mcp-client.js.map +1 -1
  81. package/dist/src/mcp-tools/embeddings-tools.d.ts.map +1 -1
  82. package/dist/src/mcp-tools/embeddings-tools.js +16 -8
  83. package/dist/src/mcp-tools/embeddings-tools.js.map +1 -1
  84. package/dist/src/mcp-tools/knowledge-tools.d.ts.map +1 -1
  85. package/dist/src/mcp-tools/knowledge-tools.js +27 -13
  86. package/dist/src/mcp-tools/knowledge-tools.js.map +1 -1
  87. package/dist/src/memory/memory-bridge.d.ts +5 -1
  88. package/dist/src/memory/memory-bridge.d.ts.map +1 -1
  89. package/dist/src/memory/memory-bridge.js +26 -3
  90. package/dist/src/memory/memory-bridge.js.map +1 -1
  91. package/dist/src/memory/memory-read.d.ts +9 -0
  92. package/dist/src/memory/memory-read.d.ts.map +1 -1
  93. package/dist/src/memory/memory-read.js +13 -2
  94. package/dist/src/memory/memory-read.js.map +1 -1
  95. package/dist/src/monovector/diff-classifier.d.ts.map +1 -1
  96. package/dist/src/monovector/diff-classifier.js +25 -6
  97. package/dist/src/monovector/diff-classifier.js.map +1 -1
  98. package/dist/src/orgrt/daemon.d.ts +21 -1
  99. package/dist/src/orgrt/daemon.d.ts.map +1 -1
  100. package/dist/src/orgrt/daemon.js +82 -11
  101. package/dist/src/orgrt/daemon.js.map +1 -1
  102. package/dist/src/orgrt/inbox.d.ts.map +1 -1
  103. package/dist/src/orgrt/inbox.js +53 -20
  104. package/dist/src/orgrt/inbox.js.map +1 -1
  105. package/dist/src/parser.d.ts +4 -2
  106. package/dist/src/parser.d.ts.map +1 -1
  107. package/dist/src/parser.js +61 -25
  108. package/dist/src/parser.js.map +1 -1
  109. package/dist/src/routing/route-layer-factory.d.ts +15 -0
  110. package/dist/src/routing/route-layer-factory.d.ts.map +1 -1
  111. package/dist/src/routing/route-layer-factory.js +44 -2
  112. package/dist/src/routing/route-layer-factory.js.map +1 -1
  113. package/dist/src/services/config-file-manager.d.ts +10 -2
  114. package/dist/src/services/config-file-manager.d.ts.map +1 -1
  115. package/dist/src/services/config-file-manager.js +10 -2
  116. package/dist/src/services/config-file-manager.js.map +1 -1
  117. package/dist/src/ui/collector.mjs +43 -3
  118. package/dist/src/ui/dashboard.html +27 -8
  119. package/dist/src/ui/server.mjs +144 -14
  120. package/dist/tsconfig.tsbuildinfo +1 -1
  121. package/package.json +7 -6
  122. package/dist/src/capabilities/watcher.d.ts +0 -18
  123. package/dist/src/capabilities/watcher.d.ts.map +0 -1
  124. package/dist/src/capabilities/watcher.js +0 -107
  125. package/dist/src/capabilities/watcher.js.map +0 -1
  126. package/dist/src/config-adapter.d.ts +0 -16
  127. package/dist/src/config-adapter.d.ts.map +0 -1
  128. package/dist/src/config-adapter.js +0 -220
  129. package/dist/src/config-adapter.js.map +0 -1
@@ -119,11 +119,48 @@ function collectSessions(projectDir) {
119
119
  };
120
120
  }
121
121
 
122
+ /**
123
+ * Canonical monomind data root for a project, mirroring getMonomindDataRoot()
124
+ * in src/mcp-tools/types.ts: inside a git repo the stores live under
125
+ * `<repo>/.git/monomind`, NOT `<repo>/.monomind`.
126
+ *
127
+ * The dashboard read only the `.monomind` path, so in any git repo — i.e. every
128
+ * real project — it rendered swarm data from a stale or absent file while the
129
+ * MCP tools wrote the live state somewhere else.
130
+ */
131
+ function monomindDataRoot(projectDir) {
132
+ if (process.env.MONOMIND_DATA_DIR) return process.env.MONOMIND_DATA_DIR;
133
+ try {
134
+ const gitEntry = path.join(projectDir, '.git');
135
+ const st = fs.statSync(gitEntry);
136
+ if (st.isDirectory()) return path.join(gitEntry, 'monomind');
137
+ const m = fs.readFileSync(gitEntry, 'utf8').match(/^gitdir:\s*(.+)/m);
138
+ if (m) {
139
+ const worktreeDir = path.resolve(projectDir, m[1].trim());
140
+ return path.join(path.dirname(path.dirname(worktreeDir)), 'monomind');
141
+ }
142
+ } catch { /* not a git repo — fall through */ }
143
+ return path.join(projectDir, '.monomind');
144
+ }
145
+
146
+ /** Read the first of several candidate paths that yields data. */
147
+ function readFirstJSON(...candidates) {
148
+ for (const c of candidates) {
149
+ const v = readJSON(c);
150
+ if (v) return v;
151
+ }
152
+ return null;
153
+ }
154
+
122
155
  const _appendedSwarmIds = new Set();
123
156
 
124
157
  function collectSwarm(projectDir) {
125
- const base = path.join(projectDir, '.monomind');
126
- const state = readJSON(path.join(base, 'swarm', 'swarm-state.json')) || {};
158
+ // Canonical location first, legacy `<cwd>/.monomind` second so a project
159
+ // written by an older CLI still renders.
160
+ const state = readFirstJSON(
161
+ path.join(monomindDataRoot(projectDir), 'swarm', 'swarm-state.json'),
162
+ path.join(projectDir, '.monomind', 'swarm', 'swarm-state.json'),
163
+ ) || {};
127
164
  const dotSwarmState = readJSON(path.join(projectDir, '.swarm', 'state.json')) || {};
128
165
  const merged = { ...dotSwarmState, ...state };
129
166
 
@@ -187,7 +224,10 @@ function collectSwarmHistory(projectDir) {
187
224
  // this format never has. So without this fallback the tab is empty even
188
225
  // though swarms exist. Derive entries directly from the map here.
189
226
  try {
190
- const state = readJSON(path.join(projectDir, '.monomind', 'swarm', 'swarm-state.json')) || {};
227
+ const state = readFirstJSON(
228
+ path.join(monomindDataRoot(projectDir), 'swarm', 'swarm-state.json'),
229
+ path.join(projectDir, '.monomind', 'swarm', 'swarm-state.json'),
230
+ ) || {};
191
231
  const swarmsMap = state.swarms || (state.swarmId ? { [state.swarmId]: state } : {});
192
232
  for (const [key, s] of Object.entries(swarmsMap)) {
193
233
  if (!s || typeof s !== 'object') continue;
@@ -5207,6 +5207,9 @@ function renderModelMix() {
5207
5207
  if (!breakdown[model]) breakdown[model] = { calls: 0, cost: 0 };
5208
5208
  breakdown[model].calls += d.calls || 0;
5209
5209
  breakdown[model].cost += d.cost || 0;
5210
+ // The server flags models missing from its pricing table. Their cost
5211
+ // contribution is 0 because it is UNKNOWN, not because it was free.
5212
+ if (d.unknownPricing) breakdown[model].unknownPricing = true;
5210
5213
  }
5211
5214
  }
5212
5215
  const entries = Object.entries(breakdown).sort((a,b) => b[1].cost - a[1].cost);
@@ -5220,11 +5223,14 @@ function renderModelMix() {
5220
5223
  </tr></thead><tbody>
5221
5224
  ${entries.map(([model, d]) => {
5222
5225
  const short = model.replace(/^claude-/,'').replace(/-\d{8}$/,'');
5223
- const pct = totalCost > 0 ? Math.round(d.cost/totalCost*100) : 0;
5226
+ const pct = (totalCost > 0 && !d.unknownPricing) ? Math.round(d.cost/totalCost*100) : 0;
5227
+ const costCell = d.unknownPricing
5228
+ ? `<span title="No pricing data for this model — spend is unknown, not zero" style="color:var(--text-lo)">unknown</span>`
5229
+ : `$${(d.cost || 0).toFixed(2)}`;
5224
5230
  return `<tr>
5225
5231
  <td style="font-size:11px">${esc(short)}</td>
5226
- <td class="lb-cost">$${d.cost.toFixed(2)}</td>
5227
- <td class="lb-dur">${pct}%</td>
5232
+ <td class="lb-cost">${costCell}</td>
5233
+ <td class="lb-dur">${d.unknownPricing ? '—' : pct + '%'}</td>
5228
5234
  <td class="lb-dur">${d.calls}</td>
5229
5235
  </tr>`;
5230
5236
  }).join('')}
@@ -10240,10 +10246,14 @@ function buildModelDonut() {
10240
10246
  if (!breakdown[model]) breakdown[model] = { calls: 0, cost: 0 };
10241
10247
  breakdown[model].calls += d.calls || 0;
10242
10248
  breakdown[model].cost += d.cost || 0;
10249
+ if (d.unknownPricing) breakdown[model].unknownPricing = true;
10243
10250
  }
10244
10251
  }
10245
10252
  const entries = Object.entries(breakdown).sort((a, b) => b[1].cost - a[1].cost);
10246
10253
  if (!entries.length) { el.innerHTML = '<div class="loading-txt" style="padding:8px">No model breakdown data</div>'; return; }
10254
+ // Unpriced models contribute 0 to every slice; say so rather than implying the
10255
+ // donut covers all spend.
10256
+ const _unpricedModels = entries.filter(([, d]) => d.unknownPricing).map(([m]) => m);
10247
10257
  const totalCost = entries.reduce((a, [, d]) => a + d.cost, 0);
10248
10258
 
10249
10259
  // Build SVG conic-gradient-style donut using stroke-dasharray
@@ -10259,19 +10269,25 @@ function buildModelDonut() {
10259
10269
  const svg = `<svg class="donut-svg" width="88" height="88" viewBox="0 0 88 88">
10260
10270
  <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="var(--surface-hi)" stroke-width="14"/>
10261
10271
  ${segments}
10262
- <text x="${CX}" y="${CY}" text-anchor="middle" dy="0.3em" font-size="9" fill="var(--text-xs)" font-family="var(--mono)">$${totalCost.toFixed(2)}</text>
10272
+ <text x="${CX}" y="${CY}" text-anchor="middle" dy="0.3em" font-size="9" fill="var(--text-xs)" font-family="var(--mono)">${_unpricedModels.length ? '≥' : ''}$${totalCost.toFixed(2)}</text>
10263
10273
  </svg>`;
10264
10274
 
10265
10275
  const legend = entries.slice(0, 6).map(([model, d], i) => {
10266
10276
  const short = model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
10267
10277
  const pct = totalCost > 0 ? Math.round(d.cost / totalCost * 100) : 0;
10278
+ const costTxt = d.unknownPricing
10279
+ ? `<span title="No pricing data for this model">unknown</span>`
10280
+ : `$${(d.cost || 0).toFixed(2)} <span style="color:var(--text-xs)">${pct}%</span>`;
10268
10281
  return `<div class="donut-item"><div class="donut-swatch" style="background:${DONUT_PALETTE[i % DONUT_PALETTE.length]}"></div>
10269
10282
  <span class="donut-name" title="${esc(model)}">${esc(short)}</span>
10270
- <span class="donut-cost">$${d.cost.toFixed(2)} <span style="color:var(--text-xs)">${pct}%</span></span>
10283
+ <span class="donut-cost">${costTxt}</span>
10271
10284
  </div>`;
10272
10285
  }).join('');
10273
10286
 
10274
- el.innerHTML = `<div class="donut-wrap">${svg}<div class="donut-legend">${legend}</div></div>`;
10287
+ const unpricedNote = _unpricedModels.length
10288
+ ? `<div class="loading-txt" style="padding:4px 8px;font-size:10px">Total excludes ${_unpricedModels.length} model(s) with no pricing data: ${esc(_unpricedModels.join(', '))}</div>`
10289
+ : '';
10290
+ el.innerHTML = `<div class="donut-wrap">${svg}<div class="donut-legend">${legend}</div></div>${unpricedNote}`;
10275
10291
  }
10276
10292
 
10277
10293
  // ── feature 60: cost anomaly explainer ────────────────────
@@ -10289,9 +10305,12 @@ function showCostExplainer(sessId, event) {
10289
10305
  const pct = costsArr.length ? Math.round(costsArr.filter(c=>c<=(sess.totalCost||0)).length/costsArr.length*100) : 0;
10290
10306
  const ratio = median > 0 ? ((sess.totalCost||0)/median).toFixed(1) : '—';
10291
10307
  const modelRows = Object.entries(sess.modelBreakdown||{}).sort((a,b)=>b[1].cost-a[1].cost).slice(0,4)
10292
- .map(([m,d])=>`<div class="err-item">${esc(m.replace(/^claude-/,'').replace(/-\d{8}$/,''))}: $${(d.cost||0).toFixed(4)} · ${d.calls||0} calls</div>`).join('');
10308
+ .map(([m,d])=>`<div class="err-item">${esc(m.replace(/^claude-/,'').replace(/-\d{8}$/,''))}: ${d.unknownPricing ? 'unknown (no pricing data)' : '$'+(d.cost||0).toFixed(4)} · ${d.calls||0} calls</div>`).join('');
10309
+ // costIncomplete => the server could not price at least one model, so the
10310
+ // session total is a floor, not the actual spend.
10311
+ const costPrefix = sess.costIncomplete ? '≥' : '';
10293
10312
  drawer.innerHTML = `<div class="err-drawer-head" style="color:oklch(70% 0.18 80)">
10294
- <span>Cost anomaly — $${(sess.totalCost||0).toFixed(3)} (${ratio}× median, top ${100-pct}%)</span>
10313
+ <span>Cost anomaly — ${costPrefix}$${(sess.totalCost||0).toFixed(3)} (${ratio}× median, top ${100-pct}%)</span>
10295
10314
  <button class="err-close" onclick="this.closest('.err-drawer').classList.remove('open');this.closest('.err-drawer').innerHTML=''">✕</button>
10296
10315
  </div>
10297
10316
  <div class="err-drawer-body">
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import crypto from 'crypto';
6
+ import zlib from 'zlib';
6
7
  import { fileURLToPath } from 'url';
7
8
  import { createRequire } from 'module';
8
9
  import { spawn } from 'child_process';
@@ -41,6 +42,16 @@ function _sjGetPricing(model) {
41
42
  for (const k of Object.keys(_SJ_PRICING)) { if (canonical.startsWith(k) || canonical.includes(k)) return _SJ_PRICING[k]; }
42
43
  return null;
43
44
  }
45
+ /**
46
+ * True when this model has a pricing row (directly, by alias, or by prefix match).
47
+ * A model with no row cannot be costed — every caller that sums _sjCalcCost() must
48
+ * use this to mark the total as INCOMPLETE rather than presenting the missing
49
+ * contribution as a genuine $0.00. The table above is a hand-maintained snapshot,
50
+ * so newly released models land here routinely.
51
+ */
52
+ export function _sjHasPricing(model) {
53
+ return _sjGetPricing(model) !== null;
54
+ }
44
55
  function _sjCalcCost(model, usage) {
45
56
  const p = _sjGetPricing(model);
46
57
  if (!p || !usage) return 0;
@@ -586,6 +597,70 @@ function _updateRunState(event, rootDir) {
586
597
  }
587
598
  // ── End runstate helpers ─────────────────────────────────────────────────
588
599
 
600
+ // ── Security: DNS-rebinding defence ───────────────────────────────────────────
601
+ // The dashboard binds 127.0.0.1 and embeds a live auth token in the HTML served
602
+ // by GET / (an open route — the page needs the token before it can attach it to
603
+ // any fetch). Loopback binding is NOT a boundary against a browser: a page on
604
+ // attacker.example can point its own DNS at 127.0.0.1, and the browser will
605
+ // happily connect and treat the response as same-origin with attacker.example,
606
+ // letting the attacker's script read the token straight out of the <meta> tag
607
+ // and then drive every authenticated /api/* route.
608
+ //
609
+ // The distinguishing signal is the Host header: the browser sends the name from
610
+ // the URL bar, so a rebound request carries `Host: attacker.example` while the
611
+ // real dashboard carries a loopback name. Rejecting non-loopback Host values
612
+ // closes the hole without affecting any legitimate client. (CORS does not help
613
+ // here — the rebound page IS same-origin as far as the browser is concerned.)
614
+ const _LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
615
+
616
+ /**
617
+ * Extract the hostname from a Host header, dropping the port.
618
+ * Returns '' for a missing/blank header. IPv6 literals keep their brackets
619
+ * (`[::1]:4242` → `[::1]`), which is how they appear in a Host header.
620
+ */
621
+ export function parseHostHeader(hostHeader) {
622
+ const h = String(hostHeader ?? '').trim().toLowerCase();
623
+ if (!h) return '';
624
+ if (h.startsWith('[')) {
625
+ const end = h.indexOf(']');
626
+ return end === -1 ? h : h.slice(0, end + 1);
627
+ }
628
+ const colon = h.lastIndexOf(':');
629
+ return colon === -1 ? h : h.slice(0, colon);
630
+ }
631
+
632
+ /**
633
+ * True when a request's Host header names this machine's loopback interface or
634
+ * an explicitly configured extra host.
635
+ *
636
+ * A request with NO Host header is allowed: HTTP/1.0 and raw local scripts omit
637
+ * it, and it is not an attack vector — browsers ALWAYS send Host, so a rebinding
638
+ * page cannot suppress it.
639
+ *
640
+ * `extraHosts` comes from startServer({ allowedHosts }) or the
641
+ * MONOMIND_DASHBOARD_ALLOWED_HOSTS env var (comma-separated), for the case where
642
+ * someone deliberately fronts the dashboard with another name.
643
+ */
644
+ export function isAllowedHost(hostHeader, extraHosts = []) {
645
+ const name = parseHostHeader(hostHeader);
646
+ if (!name) return true; // absent Host — non-browser client, see above
647
+ if (_LOOPBACK_HOSTNAMES.has(name)) return true;
648
+ // 127.0.0.0/8 is entirely loopback, not just 127.0.0.1.
649
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
650
+ for (const extra of extraHosts) {
651
+ if (parseHostHeader(extra) === name) return true;
652
+ }
653
+ return false;
654
+ }
655
+
656
+ /** Parse the configured extra-host allow-list from an option + env var. */
657
+ export function resolveAllowedHosts(optionValue) {
658
+ const raw = []
659
+ .concat(Array.isArray(optionValue) ? optionValue : (optionValue ? [optionValue] : []))
660
+ .concat(String(process.env.MONOMIND_DASHBOARD_ALLOWED_HOSTS || '').split(','));
661
+ return raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
662
+ }
663
+
589
664
  // Server state
590
665
  let running = false;
591
666
  let currentPort = null;
@@ -707,7 +782,9 @@ function _resolveSlugToPathUncached(slug, projDir) {
707
782
  return slug.replace(/-/g, '/');
708
783
  }
709
784
 
710
- export async function startServer({ port = 4242, projectDir, openBrowser = true } = {}) {
785
+ export async function startServer({ port = 4242, projectDir, openBrowser = true, allowedHosts } = {}) {
786
+ // Extra Host names accepted beyond loopback (see isAllowedHost above).
787
+ const _allowedHosts = resolveAllowedHosts(allowedHosts);
711
788
  // ── Security: per-process auth credential for mutating (non-GET) requests ─
712
789
  // Generated once per server start and written to a well-known location so
713
790
  // trusted local callers (CLI, control-start.cjs) can read it and pass it
@@ -976,7 +1053,10 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
976
1053
  const _coldPath = _wp.replace('.warm.jsonl', '.cold.jsonl.gz');
977
1054
  if (fs.existsSync(_coldPath)) return; // already compacted
978
1055
  const _warmData = fs.readFileSync(_wp);
979
- const zlib = require('zlib');
1056
+ // `zlib` is a static ESM import at the top of this file. It used to be
1057
+ // `require('zlib')`, which is undefined in an ESM module — the
1058
+ // ReferenceError was swallowed by the surrounding catch, so cold-tier
1059
+ // compaction silently never ran. See tests/repo/no-cjs-require-in-esm.
980
1060
  zlib.gzip(_warmData, (_err, _gz) => {
981
1061
  if (_err) return;
982
1062
  try {
@@ -1246,6 +1326,16 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
1246
1326
  const server = http.createServer(async (req, res) => {
1247
1327
  const url = req.url.split('?')[0];
1248
1328
 
1329
+ // ── Security: DNS-rebinding defence (see isAllowedHost) ─────────────────
1330
+ // Must run before ANY route — including the open ones, since GET / is what
1331
+ // hands out the auth token. Fails closed with a bare 403 and no CORS header.
1332
+ if (!isAllowedHost(req.headers.host, _allowedHosts)) {
1333
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
1334
+ res.end('Forbidden: Host header is not a loopback address. '
1335
+ + 'Set MONOMIND_DASHBOARD_ALLOWED_HOSTS to permit another host name.\n');
1336
+ return;
1337
+ }
1338
+
1249
1339
  // ── Security: strict CORS allow-list ────────────────────────────────────
1250
1340
  // Only reflect Origin when it is this dashboard's own loopback origin.
1251
1341
  // Otherwise the header is omitted entirely, so cross-origin reads fail
@@ -1436,6 +1526,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
1436
1526
  const id = f.replace('.jsonl', '');
1437
1527
  let lastPrompt = '', summaries = [], totalDurationMs = 0, totalMessages = 0, firstTs = null, lastTs = null, totalCost = 0, toolCalls = 0, userMessages = 0, cacheReadTokens = 0, totalInputTokens = 0, errorCount = 0;
1438
1528
  const modelBreakdown = {};
1529
+ const unknownPricingModels = new Set();
1439
1530
  const filesTouchedSet = new Set();
1440
1531
  try {
1441
1532
  const lines = _readTailLines(fp);
@@ -1478,6 +1569,12 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
1478
1569
  if (!modelBreakdown[mk]) modelBreakdown[mk] = { calls: 0, cost: 0 };
1479
1570
  modelBreakdown[mk].calls++;
1480
1571
  modelBreakdown[mk].cost += c;
1572
+ // A model absent from the pricing table contributes 0 to the sum. Flag it
1573
+ // instead of letting the UI render an authoritative-looking $0.00.
1574
+ if (!_sjHasPricing(msg.model)) {
1575
+ modelBreakdown[mk].unknownPricing = true;
1576
+ unknownPricingModels.add(mk);
1577
+ }
1481
1578
  cacheReadTokens += (msg.usage.cache_read_input_tokens || 0);
1482
1579
  totalInputTokens += (msg.usage.input_tokens || 0)
1483
1580
  + (msg.usage.cache_creation_input_tokens || 0)
@@ -1493,7 +1590,9 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
1493
1590
  const filesTouched = [...filesTouchedSet].slice(0, 20);
1494
1591
  const compactCount = summaries.length;
1495
1592
  const summary = summaries.length ? summaries[summaries.length - 1].text : null;
1496
- sessions.push({ id, mtime, firstTs, lastTs, lastPrompt, summaries, summary, compactCount, errorCount, totalDurationMs, totalMessages, totalCost, toolCalls, userMessages, cacheReadTokens, totalInputTokens, modelBreakdown, filesTouched, file: fp });
1593
+ // costIncomplete => totalCost is a LOWER BOUND: at least one model had no
1594
+ // pricing row, so its spend is missing from the sum entirely.
1595
+ sessions.push({ id, mtime, firstTs, lastTs, lastPrompt, summaries, summary, compactCount, errorCount, totalDurationMs, totalMessages, totalCost, costIncomplete: unknownPricingModels.size > 0, unknownPricingModels: [...unknownPricingModels], toolCalls, userMessages, cacheReadTokens, totalInputTokens, modelBreakdown, filesTouched, file: fp });
1497
1596
  }
1498
1597
  res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
1499
1598
  res.end(JSON.stringify({ sessions }));
@@ -1740,16 +1839,29 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
1740
1839
  try { sessionFiles = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('._')).map(f => path.join(projDir, f)); } catch {}
1741
1840
  if (!sessionFiles.length) continue;
1742
1841
  let totalCost = 0;
1842
+ const unknownPricingModels = new Set();
1743
1843
  for (const fp of sessionFiles) {
1744
1844
  try {
1745
1845
  const lines = fs.readFileSync(fp, 'utf8').split('\n').filter(Boolean);
1746
1846
  for (const line of lines) {
1747
1847
  let e; try { e = JSON.parse(line); } catch { continue; }
1748
- if (e.type === 'assistant' && e.message?.usage) { totalCost += _sjCalcCost(e.message.model || '', e.message.usage); }
1848
+ if (e.type === 'assistant' && e.message?.usage) {
1849
+ const _m = e.message.model || '';
1850
+ totalCost += _sjCalcCost(_m, e.message.usage);
1851
+ if (!_sjHasPricing(_m)) unknownPricingModels.add(_m.replace(/@.*$/, '').replace(/-\d{8}$/, ''));
1852
+ }
1749
1853
  }
1750
1854
  } catch {}
1751
1855
  }
1752
- if (totalCost > 0) projectCosts.push({ path: projPath, cost: totalCost, sessions: sessionFiles.length });
1856
+ // Include projects whose entire spend is on unpriced models previously they
1857
+ // were dropped by `totalCost > 0` and simply vanished from the cost list.
1858
+ if (totalCost > 0 || unknownPricingModels.size > 0) {
1859
+ projectCosts.push({
1860
+ path: projPath, cost: totalCost, sessions: sessionFiles.length,
1861
+ costIncomplete: unknownPricingModels.size > 0,
1862
+ unknownPricingModels: [...unknownPricingModels],
1863
+ });
1864
+ }
1753
1865
  }
1754
1866
  projectCosts.sort((a, b) => b.cost - a.cost);
1755
1867
  res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
@@ -3845,7 +3957,7 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
3845
3957
 
3846
3958
  const toolCounts = {};
3847
3959
  const agentSpawns = {}; // subagent_type → count
3848
- let turns = 0, totalCost = 0;
3960
+ let turns = 0, totalCost = 0, _costIncomplete = false;
3849
3961
 
3850
3962
  try {
3851
3963
  const raw = fs.readFileSync(fp, 'utf8').replace(/\r\n/g, '\n');
@@ -3868,7 +3980,11 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
3868
3980
  agentSpawns[sub] = (agentSpawns[sub] || 0) + 1;
3869
3981
  }
3870
3982
  }
3871
- if (e.message?.usage) totalCost += _sjCalcCost(e.message.model || '', e.message.usage);
3983
+ if (e.message?.usage) {
3984
+ const _m = e.message.model || '';
3985
+ totalCost += _sjCalcCost(_m, e.message.usage);
3986
+ if (!_sjHasPricing(_m)) _costIncomplete = true;
3987
+ }
3872
3988
  }
3873
3989
  }
3874
3990
  } catch {}
@@ -3877,7 +3993,7 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
3877
3993
  nodes.push({
3878
3994
  id: sid, type: 'session', label: sid.slice(0,8),
3879
3995
  turns, totalTools, toolCounts,
3880
- cost: totalCost, mtime: stat.mtimeMs, size: stat.size,
3996
+ cost: totalCost, costIncomplete: _costIncomplete, mtime: stat.mtimeMs, size: stat.size,
3881
3997
  agentSpawns
3882
3998
  });
3883
3999
 
@@ -6260,9 +6376,23 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
6260
6376
  if (wantKg && scope !== 'global') {
6261
6377
  try { kgSearch = (await import('../memory/memory-kg.js')).kgSearch; } catch { /* kg unavailable */ }
6262
6378
  }
6263
- const [proj, glob, rules, graph, mems] = await Promise.all([
6264
- scope !== 'global' ? bridge.bridgeSearchEntries({ query, namespace, limit }).catch(() => null) : null,
6265
- scope !== 'project' ? bridge.bridgeSearchEntries({ query, namespace: 'knowledge:global', limit, dbPath: '@global' }).catch(() => null) : null,
6379
+ // Superseded-version filtering the SAME rule `searchKnowledge` (and
6380
+ // therefore `monomind doc search` / `knowledge_search`) applies. This
6381
+ // endpoint queries the bridge directly for warmth, so without this it
6382
+ // would inject old document versions the Second Brain itself hides.
6383
+ let liveProj = new Set(), liveGlob = new Set(), isSuperseded = () => false, overfetch = (n) => n;
6384
+ try {
6385
+ const dp = await import('../knowledge/document-pipeline.js');
6386
+ liveProj = dp.liveContentHashes(process.env.CLAUDE_PROJECT_DIR || process.cwd());
6387
+ liveGlob = dp.liveContentHashes(process.env.MONOMIND_GLOBAL_BRAIN_DIR
6388
+ || path.join(os.homedir(), '.monomind', 'global-brain'));
6389
+ isSuperseded = dp.isSupersededKey;
6390
+ overfetch = dp.supersededOverfetchLimit;
6391
+ } catch { /* pipeline unavailable — no filtering, same as before */ }
6392
+ const keepLive = (rows, live) => (rows || []).filter(r => !isSuperseded(String(r.key || ''), live)).slice(0, limit);
6393
+ const [projRaw, globRaw, rules, graph, mems] = await Promise.all([
6394
+ scope !== 'global' ? bridge.bridgeSearchEntries({ query, namespace, limit: overfetch(limit, liveProj) }).catch(() => null) : null,
6395
+ scope !== 'project' ? bridge.bridgeSearchEntries({ query, namespace: 'knowledge:global', limit: overfetch(limit, liveGlob), dbPath: '@global' }).catch(() => null) : null,
6266
6396
  // Distilled rules ("when X do Y") learned from sessions/runs —
6267
6397
  // small namespace, high injection value, threshold keeps it quiet.
6268
6398
  scope !== 'global' && wantRules ? bridge.bridgeSearchEntries({ query, namespace: 'rules', limit: 2, threshold: 0.45 }).catch(() => null) : null,
@@ -6274,8 +6404,8 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
6274
6404
  scope !== 'global' && wantMemory ? bridge.bridgeSearchEntries({ query, namespace: 'patterns', limit: 2, threshold: 0.4 }).catch(() => null) : null,
6275
6405
  ]);
6276
6406
  const lists = [
6277
- (proj?.results || []).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, tags: r.tags, importance: 0.6 })),
6278
- (glob?.results || []).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score, global: true, tags: r.tags })),
6407
+ keepLive(projRaw?.results, liveProj).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, tags: r.tags, importance: 0.6 })),
6408
+ keepLive(globRaw?.results, liveGlob).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score, global: true, tags: r.tags })),
6279
6409
  (rules?.results || []).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, rule: true, tags: r.tags, importance: 0.7 })),
6280
6410
  (graph?.triplets || []).map((t, i) => ({
6281
6411
  id: 'kg:' + i + ':' + t.source + '|' + t.relation + '|' + t.target,
@@ -6303,7 +6433,7 @@ new Sigma(g,document.getElementById('g'),{renderEdgeLabels:false,labelColor:{col
6303
6433
  } catch { /* best effort */ }
6304
6434
  res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
6305
6435
  res.end(JSON.stringify({
6306
- method: proj?.searchMethod || glob?.searchMethod || rules?.searchMethod || mems?.searchMethod
6436
+ method: projRaw?.searchMethod || globRaw?.searchMethod || rules?.searchMethod || mems?.searchMethod
6307
6437
  || (merged.length ? 'mixed' : 'none'),
6308
6438
  results: merged,
6309
6439
  }));