@luanpdd/kit-mcp 1.34.0 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/cli.js +2 -2
- package/bin/mcp.js +6 -6
- package/bin/ui.js +74 -74
- package/gates/ai-prompt-stability.md +120 -120
- package/gates/budget-description.md +68 -68
- package/gates/confidence.md +29 -29
- package/gates/dependency-check.md +33 -33
- package/gates/dept-cycle-prevention.md +179 -179
- package/gates/golden-signals-coverage.md +133 -133
- package/gates/legacy-refactor-safety.md +178 -178
- package/gates/multi-tenant-rls-coverage.md +102 -102
- package/gates/no-personal-uuid.md +72 -72
- package/gates/obs-agents-mcp-supabase.md +86 -86
- package/gates/obs-skills-frontmatter.md +76 -76
- package/gates/observability-coverage.md +151 -151
- package/gates/omm-no-regression.md +83 -83
- package/gates/postmortem-template-required.md +127 -127
- package/gates/prr-checklist-coverage.md +128 -128
- package/gates/regression.md +32 -32
- package/gates/release-pipeline-policy.md +132 -132
- package/gates/secrets-scan.md +33 -33
- package/gates/service-role-not-in-user-facing.md +113 -113
- package/gates/skill-must-include.md +71 -71
- package/gates/sync-idempotent.md +62 -62
- package/gates/verify-phase-goal.md +34 -34
- package/kit/agents/designer-ui.md +216 -216
- package/kit/agents/workflow-generator.md +537 -0
- package/kit/commands/adicionar-backlog.md +1 -1
- package/kit/commands/adicionar-fase.md +1 -1
- package/kit/commands/adicionar-tarefa.md +1 -1
- package/kit/commands/auditar-observabilidade.md +103 -103
- package/kit/commands/auditar-toil.md +129 -129
- package/kit/commands/caracterizar-prompt.md +195 -195
- package/kit/commands/criar-workflow.md +158 -0
- package/kit/commands/definir-perfil.md +1 -1
- package/kit/commands/definir-slo.md +108 -108
- package/kit/commands/fio.md +1 -1
- package/kit/commands/golden-signals.md +142 -142
- package/kit/commands/instrumentar-fase.md +200 -200
- package/kit/commands/investigar-producao.md +162 -162
- package/kit/commands/observabilidade.md +118 -118
- package/kit/commands/postmortem.md +179 -179
- package/kit/commands/prr.md +205 -205
- package/kit/commands/publicar-rapido.md +207 -207
- package/kit/commands/risk-budget.md +220 -220
- package/kit/commands/sre.md +230 -230
- package/kit/file-manifest.json +5 -2
- package/kit/framework/references/output-style.md +22 -22
- package/kit/hooks/post-apply-migration.js +199 -199
- package/kit/hooks/sidecar-tool-publisher.js +210 -210
- package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
- package/kit/skills/_shared-legacy/glossary.md +389 -389
- package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
- package/kit/skills/_shared-observability/glossary.md +396 -396
- package/kit/skills/_shared-sre/glossary.md +712 -712
- package/kit/skills/_shared-supabase/glossary.md +234 -234
- package/kit/skills/blameless-postmortems/SKILL.md +340 -340
- package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
- package/kit/skills/cascading-failures/SKILL.md +311 -311
- package/kit/skills/core-analysis-loop/SKILL.md +352 -352
- package/kit/skills/distributed-tracing/SKILL.md +362 -362
- package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -0
- package/kit/skills/eliminating-toil/SKILL.md +243 -243
- package/kit/skills/event-based-slos/SKILL.md +296 -296
- package/kit/skills/four-golden-signals/SKILL.md +314 -314
- package/kit/skills/hermetic-builds/SKILL.md +323 -323
- package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
- package/kit/skills/llm-as-dependency/SKILL.md +436 -436
- package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
- package/kit/skills/observability-driven-development/SKILL.md +315 -315
- package/kit/skills/observability-maturity-model/SKILL.md +222 -222
- package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
- package/kit/skills/production-readiness-review/SKILL.md +305 -305
- package/kit/skills/release-engineering/SKILL.md +367 -367
- package/kit/skills/retry-strategies/SKILL.md +372 -372
- package/kit/skills/sre-risk-management/SKILL.md +221 -221
- package/kit/skills/structured-events/SKILL.md +265 -265
- package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
- package/kit/skills/supabase-database-functions/SKILL.md +332 -332
- package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
- package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
- package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
- package/kit/skills/supabase-storage/SKILL.md +234 -234
- package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
- package/kit/skills/telemetry-sampling/SKILL.md +256 -256
- package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
- package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
- package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
- package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
- package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
- package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
- package/kit/skills/ui-tipografia/SKILL.md +211 -211
- package/package.json +1 -1
- package/src/cli/index.js +1114 -1114
- package/src/cli/render.js +194 -194
- package/src/cli/upgrade-check.js +135 -135
- package/src/core/error-redaction.js +76 -76
- package/src/core/failures.js +153 -153
- package/src/core/gate-runner.js +205 -205
- package/src/core/gates.js +82 -82
- package/src/core/logger.js +170 -170
- package/src/core/manifest-verify.js +174 -174
- package/src/core/metrics.js +268 -268
- package/src/core/notify.js +60 -60
- package/src/core/path-safety.js +141 -141
- package/src/core/replays.js +120 -120
- package/src/core/ui.js +185 -185
- package/src/mcp-server/install.js +149 -149
- package/src/mcp-server/roots.js +124 -124
- package/src/ui/auto-spawn.js +113 -113
- package/src/ui/browser.js +78 -78
- package/src/ui/client.js +130 -130
- package/src/ui/events.js +65 -65
- package/src/ui/lockfile.js +191 -191
- package/src/ui/port.js +67 -67
- package/src/ui/server.js +547 -547
- package/src/ui/wrapper.js +129 -129
package/src/core/metrics.js
CHANGED
|
@@ -1,268 +1,268 @@
|
|
|
1
|
-
// OBS-18-01 / OBS-18-02 — in-memory golden signals for kit-mcp server.
|
|
2
|
-
// OBS-19-01 / OBS-19-02 / OBS-19-03 — disk-persistent rolling snapshots.
|
|
3
|
-
//
|
|
4
|
-
// Phase 94: Eat Your Own Dog Food. The skill `four-golden-signals` says any
|
|
5
|
-
// user-facing service worth its salt instruments Latency + Traffic + Errors
|
|
6
|
-
// + Saturation. The MCP server qualifies — every tool call is a request from
|
|
7
|
-
// an LLM client and tail latency / error rate are exactly the signals an
|
|
8
|
-
// operator wants when something feels off.
|
|
9
|
-
//
|
|
10
|
-
// Scope decisions (see .planning/phases/94-golden-signals-mcp-server/94-CONTEXT.md):
|
|
11
|
-
// - Zero new dependencies. Phase 99 adds fs/promises + path from stdlib only —
|
|
12
|
-
// the 6-deps budget Phase 92.01 fought to maintain and Phase 93.01 enforces
|
|
13
|
-
// in CI is preserved.
|
|
14
|
-
// - In-memory primary, on-demand persistence. The Map+array core stays
|
|
15
|
-
// in-memory; persistSnapshot writes a JSON file under .planning/metrics/
|
|
16
|
-
// snapshots/ when called. No background timer, no implicit writes — the
|
|
17
|
-
// /burn-rate-status command and metrics-snapshot tool are the writers.
|
|
18
|
-
// - Bounded memory. Histograms cap at HISTOGRAM_CAP=1000 samples per tool
|
|
19
|
-
// with FIFO drop.
|
|
20
|
-
// - Bounded disk. cleanupOldSnapshots prunes files > 30 days old on every
|
|
21
|
-
// persistSnapshot call (rolling window, no separate retention job).
|
|
22
|
-
// - Snapshot is read-only. Returns a fresh plain-object copy so callers
|
|
23
|
-
// can JSON.stringify it without exposing internal Map references.
|
|
24
|
-
// - Persisted shape includes `ts` (epoch ms) inside the JSON. We do NOT
|
|
25
|
-
// parse the filename for windowing — filesystem-safe ISO encoding
|
|
26
|
-
// (`replace(/[:.]/g, '-')`) is one-way (cannot reliably round-trip back
|
|
27
|
-
// through Date.parse) and mtime is unreliable across copy/touch. The
|
|
28
|
-
// in-file ts is authoritative.
|
|
29
|
-
//
|
|
30
|
-
// API surface (5 exports + 2 async):
|
|
31
|
-
// incrementInvocation(tool, status) — counter++ keyed `${tool}:${status}`
|
|
32
|
-
// recordLatency(tool, ms) — push to histogram, FIFO at cap
|
|
33
|
-
// snapshot() — { counters, latency } plain object
|
|
34
|
-
// reset() — clear both maps; called on boot if
|
|
35
|
-
// KIT_MCP_METRICS_RESET=1
|
|
36
|
-
// persistSnapshot(rootDir) — write {ts, counters, latency} to
|
|
37
|
-
// .planning/metrics/snapshots/<ts>.json
|
|
38
|
-
// + cleanup files > 30d
|
|
39
|
-
// loadSnapshots(rootDir, windowMs) — read all snapshots whose in-file ts
|
|
40
|
-
// is within windowMs (default 30d),
|
|
41
|
-
// sorted ascending by ts
|
|
42
|
-
//
|
|
43
|
-
// Boot-time reset honors the env var by calling reset() at module load when
|
|
44
|
-
// the flag is set. This keeps the signal "fresh" for a probe in tests or for
|
|
45
|
-
// an operator who spawned the server with the flag for a clean comparison.
|
|
46
|
-
|
|
47
|
-
import fs from 'node:fs/promises';
|
|
48
|
-
import path from 'node:path';
|
|
49
|
-
|
|
50
|
-
const HISTOGRAM_CAP = 1000;
|
|
51
|
-
const DEFAULT_RETENTION_MS = 30 * 86400 * 1000; // 30 days rolling.
|
|
52
|
-
const SNAPSHOT_DIR_REL = path.join('.planning', 'metrics', 'snapshots');
|
|
53
|
-
|
|
54
|
-
const counters = new Map(); // key: `${tool}:${status}` → count (number)
|
|
55
|
-
const histograms = new Map(); // key: tool → number[] (length ≤ HISTOGRAM_CAP)
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Increment the invocation counter for a tool/status pair.
|
|
59
|
-
*
|
|
60
|
-
* @param {string} tool Tool name as it appears in the MCP request payload.
|
|
61
|
-
* @param {'ok'|'error'} [status='ok'] Outcome of the dispatch.
|
|
62
|
-
* @returns {void}
|
|
63
|
-
*/
|
|
64
|
-
export function incrementInvocation(tool, status = 'ok') {
|
|
65
|
-
if (typeof tool !== 'string' || tool.length === 0) return;
|
|
66
|
-
const key = `${tool}:${status}`;
|
|
67
|
-
counters.set(key, (counters.get(key) ?? 0) + 1);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Record an observed latency for a tool. Drops the oldest sample (FIFO) once
|
|
72
|
-
* the per-tool histogram reaches HISTOGRAM_CAP, keeping memory bounded across
|
|
73
|
-
* long-lived MCP sessions.
|
|
74
|
-
*
|
|
75
|
-
* @param {string} tool Tool name.
|
|
76
|
-
* @param {number} ms Elapsed wall-clock time in milliseconds.
|
|
77
|
-
* @returns {void}
|
|
78
|
-
*/
|
|
79
|
-
export function recordLatency(tool, ms) {
|
|
80
|
-
if (typeof tool !== 'string' || tool.length === 0) return;
|
|
81
|
-
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return;
|
|
82
|
-
let arr = histograms.get(tool);
|
|
83
|
-
if (!arr) {
|
|
84
|
-
arr = [];
|
|
85
|
-
histograms.set(tool, arr);
|
|
86
|
-
}
|
|
87
|
-
arr.push(ms);
|
|
88
|
-
if (arr.length > HISTOGRAM_CAP) arr.shift(); // FIFO drop oldest sample
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Compute a percentile over a sorted ascending array. Linear-interpolation
|
|
93
|
-
* variant matches the typical Prometheus / Datadog reading. For N≤1000
|
|
94
|
-
* (HISTOGRAM_CAP) the sort cost on snapshot is acceptable — snapshots are
|
|
95
|
-
* read on-demand by the metrics-snapshot tool, not on every dispatch.
|
|
96
|
-
*
|
|
97
|
-
* @param {number[]} sorted Ascending-sorted samples.
|
|
98
|
-
* @param {number} p Percentile in [0, 1].
|
|
99
|
-
* @returns {number}
|
|
100
|
-
*/
|
|
101
|
-
function percentile(sorted, p) {
|
|
102
|
-
if (sorted.length === 0) return 0;
|
|
103
|
-
if (sorted.length === 1) return sorted[0];
|
|
104
|
-
const rank = p * (sorted.length - 1);
|
|
105
|
-
const lo = Math.floor(rank);
|
|
106
|
-
const hi = Math.ceil(rank);
|
|
107
|
-
if (lo === hi) return sorted[lo];
|
|
108
|
-
const frac = rank - lo;
|
|
109
|
-
return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Build a read-only snapshot of all metrics. Counters are returned as a plain
|
|
114
|
-
* object keyed `${tool}:${status}` → count. Latency is keyed by tool to a
|
|
115
|
-
* `{ p50, p95, p99, count }` triple so a single tool never appears split
|
|
116
|
-
* across status outcomes (latency observation point is a single line in the
|
|
117
|
-
* dispatcher, success and failure both record).
|
|
118
|
-
*
|
|
119
|
-
* @returns {{
|
|
120
|
-
* counters: Record<string, number>,
|
|
121
|
-
* latency: Record<string, { p50: number, p95: number, p99: number, count: number }>
|
|
122
|
-
* }}
|
|
123
|
-
*/
|
|
124
|
-
export function snapshot() {
|
|
125
|
-
const out = { counters: {}, latency: {} };
|
|
126
|
-
for (const [key, val] of counters) out.counters[key] = val;
|
|
127
|
-
for (const [tool, samples] of histograms) {
|
|
128
|
-
if (samples.length === 0) continue;
|
|
129
|
-
const sorted = [...samples].sort((a, b) => a - b);
|
|
130
|
-
out.latency[tool] = {
|
|
131
|
-
p50: percentile(sorted, 0.50),
|
|
132
|
-
p95: percentile(sorted, 0.95),
|
|
133
|
-
p99: percentile(sorted, 0.99),
|
|
134
|
-
count: samples.length,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
return out;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Clear both counters and histograms. Used by tests and by the boot-time
|
|
142
|
-
* KIT_MCP_METRICS_RESET=1 path so an operator can probe a fresh window.
|
|
143
|
-
*
|
|
144
|
-
* @returns {void}
|
|
145
|
-
*/
|
|
146
|
-
export function reset() {
|
|
147
|
-
counters.clear();
|
|
148
|
-
histograms.clear();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* OBS-19-01 — Persist the current snapshot to disk under
|
|
153
|
-
* `<rootDir>/.planning/metrics/snapshots/<timestamp>.json`. Runs the rolling
|
|
154
|
-
* cleanup of files older than `retentionMs` (default 30d) on every call so
|
|
155
|
-
* callers don't need a separate retention job.
|
|
156
|
-
*
|
|
157
|
-
* The on-disk shape is `{ ts: <epoch_ms>, counters, latency }`. The `ts` field
|
|
158
|
-
* inside the JSON — NOT the filename — is the authoritative timestamp for
|
|
159
|
-
* loadSnapshots windowing. The filename uses an ISO encoding with `:` and `.`
|
|
160
|
-
* replaced by `-` for filesystem safety; that encoding is one-way (cannot
|
|
161
|
-
* round-trip back through Date.parse), so we never parse it for ordering.
|
|
162
|
-
*
|
|
163
|
-
* @param {string} [rootDir=process.cwd()] Project root. Snapshots land under
|
|
164
|
-
* `<rootDir>/.planning/metrics/snapshots/`.
|
|
165
|
-
* @param {object} [opts]
|
|
166
|
-
* @param {number} [opts.retentionMs] Override the rolling-window age in ms.
|
|
167
|
-
* Defaults to 30 days. Tests use shorter windows to drive the cleanup path.
|
|
168
|
-
* @returns {Promise<{file: string, snap: {ts: number, counters: object, latency: object}}>}
|
|
169
|
-
*/
|
|
170
|
-
export async function persistSnapshot(rootDir = process.cwd(), opts = {}) {
|
|
171
|
-
const retentionMs = Number.isFinite(opts.retentionMs) ? opts.retentionMs : DEFAULT_RETENTION_MS;
|
|
172
|
-
const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
|
|
173
|
-
await fs.mkdir(dir, { recursive: true });
|
|
174
|
-
const ts = Date.now();
|
|
175
|
-
const snap = { ts, ...snapshot() };
|
|
176
|
-
// Filesystem-safe ISO encoding — Windows forbids `:` in paths and `.` is
|
|
177
|
-
// ambiguous with extension separators on shells with brace expansion.
|
|
178
|
-
const isoSafe = new Date(ts).toISOString().replace(/[:.]/g, '-');
|
|
179
|
-
const file = path.join(dir, `${isoSafe}.json`);
|
|
180
|
-
await fs.writeFile(file, JSON.stringify(snap, null, 2));
|
|
181
|
-
await cleanupOldSnapshots(dir, retentionMs);
|
|
182
|
-
return { file, snap };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* OBS-19-02 — Load all snapshots from disk whose in-file `ts` is within the
|
|
187
|
-
* sliding window. Returns the array sorted ascending by `ts` so consumers
|
|
188
|
-
* (`/burn-rate-status`) can compute first-vs-last deltas without re-sorting.
|
|
189
|
-
*
|
|
190
|
-
* Defensive against malformed JSON: a corrupt file is skipped silently rather
|
|
191
|
-
* than aborting the whole load. The 30d window is rolling from "now" — pass a
|
|
192
|
-
* smaller value to drive recent-only views (e.g. `60 * 60 * 1000` for last
|
|
193
|
-
* hour) when computing burn rate over a baseline window.
|
|
194
|
-
*
|
|
195
|
-
* @param {string} [rootDir=process.cwd()] Project root.
|
|
196
|
-
* @param {number} [windowMs] Sliding window in ms. Defaults to 30 days.
|
|
197
|
-
* @returns {Promise<Array<{ts: number, counters: object, latency: object}>>}
|
|
198
|
-
* Empty array if the snapshots directory does not exist.
|
|
199
|
-
*/
|
|
200
|
-
export async function loadSnapshots(rootDir = process.cwd(), windowMs = DEFAULT_RETENTION_MS) {
|
|
201
|
-
const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
|
|
202
|
-
const cutoff = Date.now() - windowMs;
|
|
203
|
-
let files;
|
|
204
|
-
try {
|
|
205
|
-
files = await fs.readdir(dir);
|
|
206
|
-
} catch {
|
|
207
|
-
return []; // Dir absent on first run — not an error.
|
|
208
|
-
}
|
|
209
|
-
const results = [];
|
|
210
|
-
for (const f of files) {
|
|
211
|
-
if (!f.endsWith('.json')) continue;
|
|
212
|
-
try {
|
|
213
|
-
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
214
|
-
const parsed = JSON.parse(raw);
|
|
215
|
-
if (Number.isFinite(parsed?.ts) && parsed.ts >= cutoff) {
|
|
216
|
-
results.push(parsed);
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
219
|
-
// Corrupt file — skip silently rather than break the whole burn-rate
|
|
220
|
-
// calculation. A future phase can surface counts via a doctor probe.
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return results.sort((a, b) => a.ts - b.ts);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* OBS-19-03 — Internal helper: delete snapshot files older than `maxAgeMs`.
|
|
228
|
-
* Called from persistSnapshot on every write so retention is implicit.
|
|
229
|
-
* Uses fs.stat().mtimeMs as the age proxy; we accept the small drift versus
|
|
230
|
-
* the in-file `ts` because cleanup is best-effort eviction, not authoritative
|
|
231
|
-
* windowing (loadSnapshots reads the in-file ts).
|
|
232
|
-
*
|
|
233
|
-
* @param {string} dir Absolute path to the snapshots directory.
|
|
234
|
-
* @param {number} maxAgeMs Files with mtime older than this are unlinked.
|
|
235
|
-
* @returns {Promise<void>}
|
|
236
|
-
*/
|
|
237
|
-
async function cleanupOldSnapshots(dir, maxAgeMs) {
|
|
238
|
-
const cutoff = Date.now() - maxAgeMs;
|
|
239
|
-
let files;
|
|
240
|
-
try {
|
|
241
|
-
files = await fs.readdir(dir);
|
|
242
|
-
} catch {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
for (const f of files) {
|
|
246
|
-
if (!f.endsWith('.json')) continue;
|
|
247
|
-
const fp = path.join(dir, f);
|
|
248
|
-
try {
|
|
249
|
-
const stat = await fs.stat(fp);
|
|
250
|
-
if (stat.mtimeMs < cutoff) await fs.unlink(fp);
|
|
251
|
-
} catch {
|
|
252
|
-
// Unlink can race with concurrent cleanup; ignore ENOENT and friends.
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Boot-time reset honors KIT_MCP_METRICS_RESET=1. We call reset() instead of
|
|
258
|
-
// merely skipping init because the maps are already empty at module load —
|
|
259
|
-
// the call is a no-op today but documents the contract for any future module
|
|
260
|
-
// that imports metrics.js after another module has already populated state.
|
|
261
|
-
if (process.env.KIT_MCP_METRICS_RESET === '1') {
|
|
262
|
-
reset();
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Exported for tests only — keeps the API surface explicit while letting unit
|
|
266
|
-
// tests assert on the FIFO behavior at the boundary.
|
|
267
|
-
export const __TEST_HISTOGRAM_CAP = HISTOGRAM_CAP;
|
|
268
|
-
export const __TEST_SNAPSHOT_DIR_REL = SNAPSHOT_DIR_REL;
|
|
1
|
+
// OBS-18-01 / OBS-18-02 — in-memory golden signals for kit-mcp server.
|
|
2
|
+
// OBS-19-01 / OBS-19-02 / OBS-19-03 — disk-persistent rolling snapshots.
|
|
3
|
+
//
|
|
4
|
+
// Phase 94: Eat Your Own Dog Food. The skill `four-golden-signals` says any
|
|
5
|
+
// user-facing service worth its salt instruments Latency + Traffic + Errors
|
|
6
|
+
// + Saturation. The MCP server qualifies — every tool call is a request from
|
|
7
|
+
// an LLM client and tail latency / error rate are exactly the signals an
|
|
8
|
+
// operator wants when something feels off.
|
|
9
|
+
//
|
|
10
|
+
// Scope decisions (see .planning/phases/94-golden-signals-mcp-server/94-CONTEXT.md):
|
|
11
|
+
// - Zero new dependencies. Phase 99 adds fs/promises + path from stdlib only —
|
|
12
|
+
// the 6-deps budget Phase 92.01 fought to maintain and Phase 93.01 enforces
|
|
13
|
+
// in CI is preserved.
|
|
14
|
+
// - In-memory primary, on-demand persistence. The Map+array core stays
|
|
15
|
+
// in-memory; persistSnapshot writes a JSON file under .planning/metrics/
|
|
16
|
+
// snapshots/ when called. No background timer, no implicit writes — the
|
|
17
|
+
// /burn-rate-status command and metrics-snapshot tool are the writers.
|
|
18
|
+
// - Bounded memory. Histograms cap at HISTOGRAM_CAP=1000 samples per tool
|
|
19
|
+
// with FIFO drop.
|
|
20
|
+
// - Bounded disk. cleanupOldSnapshots prunes files > 30 days old on every
|
|
21
|
+
// persistSnapshot call (rolling window, no separate retention job).
|
|
22
|
+
// - Snapshot is read-only. Returns a fresh plain-object copy so callers
|
|
23
|
+
// can JSON.stringify it without exposing internal Map references.
|
|
24
|
+
// - Persisted shape includes `ts` (epoch ms) inside the JSON. We do NOT
|
|
25
|
+
// parse the filename for windowing — filesystem-safe ISO encoding
|
|
26
|
+
// (`replace(/[:.]/g, '-')`) is one-way (cannot reliably round-trip back
|
|
27
|
+
// through Date.parse) and mtime is unreliable across copy/touch. The
|
|
28
|
+
// in-file ts is authoritative.
|
|
29
|
+
//
|
|
30
|
+
// API surface (5 exports + 2 async):
|
|
31
|
+
// incrementInvocation(tool, status) — counter++ keyed `${tool}:${status}`
|
|
32
|
+
// recordLatency(tool, ms) — push to histogram, FIFO at cap
|
|
33
|
+
// snapshot() — { counters, latency } plain object
|
|
34
|
+
// reset() — clear both maps; called on boot if
|
|
35
|
+
// KIT_MCP_METRICS_RESET=1
|
|
36
|
+
// persistSnapshot(rootDir) — write {ts, counters, latency} to
|
|
37
|
+
// .planning/metrics/snapshots/<ts>.json
|
|
38
|
+
// + cleanup files > 30d
|
|
39
|
+
// loadSnapshots(rootDir, windowMs) — read all snapshots whose in-file ts
|
|
40
|
+
// is within windowMs (default 30d),
|
|
41
|
+
// sorted ascending by ts
|
|
42
|
+
//
|
|
43
|
+
// Boot-time reset honors the env var by calling reset() at module load when
|
|
44
|
+
// the flag is set. This keeps the signal "fresh" for a probe in tests or for
|
|
45
|
+
// an operator who spawned the server with the flag for a clean comparison.
|
|
46
|
+
|
|
47
|
+
import fs from 'node:fs/promises';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
|
|
50
|
+
const HISTOGRAM_CAP = 1000;
|
|
51
|
+
const DEFAULT_RETENTION_MS = 30 * 86400 * 1000; // 30 days rolling.
|
|
52
|
+
const SNAPSHOT_DIR_REL = path.join('.planning', 'metrics', 'snapshots');
|
|
53
|
+
|
|
54
|
+
const counters = new Map(); // key: `${tool}:${status}` → count (number)
|
|
55
|
+
const histograms = new Map(); // key: tool → number[] (length ≤ HISTOGRAM_CAP)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Increment the invocation counter for a tool/status pair.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} tool Tool name as it appears in the MCP request payload.
|
|
61
|
+
* @param {'ok'|'error'} [status='ok'] Outcome of the dispatch.
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
export function incrementInvocation(tool, status = 'ok') {
|
|
65
|
+
if (typeof tool !== 'string' || tool.length === 0) return;
|
|
66
|
+
const key = `${tool}:${status}`;
|
|
67
|
+
counters.set(key, (counters.get(key) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record an observed latency for a tool. Drops the oldest sample (FIFO) once
|
|
72
|
+
* the per-tool histogram reaches HISTOGRAM_CAP, keeping memory bounded across
|
|
73
|
+
* long-lived MCP sessions.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} tool Tool name.
|
|
76
|
+
* @param {number} ms Elapsed wall-clock time in milliseconds.
|
|
77
|
+
* @returns {void}
|
|
78
|
+
*/
|
|
79
|
+
export function recordLatency(tool, ms) {
|
|
80
|
+
if (typeof tool !== 'string' || tool.length === 0) return;
|
|
81
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return;
|
|
82
|
+
let arr = histograms.get(tool);
|
|
83
|
+
if (!arr) {
|
|
84
|
+
arr = [];
|
|
85
|
+
histograms.set(tool, arr);
|
|
86
|
+
}
|
|
87
|
+
arr.push(ms);
|
|
88
|
+
if (arr.length > HISTOGRAM_CAP) arr.shift(); // FIFO drop oldest sample
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compute a percentile over a sorted ascending array. Linear-interpolation
|
|
93
|
+
* variant matches the typical Prometheus / Datadog reading. For N≤1000
|
|
94
|
+
* (HISTOGRAM_CAP) the sort cost on snapshot is acceptable — snapshots are
|
|
95
|
+
* read on-demand by the metrics-snapshot tool, not on every dispatch.
|
|
96
|
+
*
|
|
97
|
+
* @param {number[]} sorted Ascending-sorted samples.
|
|
98
|
+
* @param {number} p Percentile in [0, 1].
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
function percentile(sorted, p) {
|
|
102
|
+
if (sorted.length === 0) return 0;
|
|
103
|
+
if (sorted.length === 1) return sorted[0];
|
|
104
|
+
const rank = p * (sorted.length - 1);
|
|
105
|
+
const lo = Math.floor(rank);
|
|
106
|
+
const hi = Math.ceil(rank);
|
|
107
|
+
if (lo === hi) return sorted[lo];
|
|
108
|
+
const frac = rank - lo;
|
|
109
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build a read-only snapshot of all metrics. Counters are returned as a plain
|
|
114
|
+
* object keyed `${tool}:${status}` → count. Latency is keyed by tool to a
|
|
115
|
+
* `{ p50, p95, p99, count }` triple so a single tool never appears split
|
|
116
|
+
* across status outcomes (latency observation point is a single line in the
|
|
117
|
+
* dispatcher, success and failure both record).
|
|
118
|
+
*
|
|
119
|
+
* @returns {{
|
|
120
|
+
* counters: Record<string, number>,
|
|
121
|
+
* latency: Record<string, { p50: number, p95: number, p99: number, count: number }>
|
|
122
|
+
* }}
|
|
123
|
+
*/
|
|
124
|
+
export function snapshot() {
|
|
125
|
+
const out = { counters: {}, latency: {} };
|
|
126
|
+
for (const [key, val] of counters) out.counters[key] = val;
|
|
127
|
+
for (const [tool, samples] of histograms) {
|
|
128
|
+
if (samples.length === 0) continue;
|
|
129
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
130
|
+
out.latency[tool] = {
|
|
131
|
+
p50: percentile(sorted, 0.50),
|
|
132
|
+
p95: percentile(sorted, 0.95),
|
|
133
|
+
p99: percentile(sorted, 0.99),
|
|
134
|
+
count: samples.length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clear both counters and histograms. Used by tests and by the boot-time
|
|
142
|
+
* KIT_MCP_METRICS_RESET=1 path so an operator can probe a fresh window.
|
|
143
|
+
*
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
export function reset() {
|
|
147
|
+
counters.clear();
|
|
148
|
+
histograms.clear();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* OBS-19-01 — Persist the current snapshot to disk under
|
|
153
|
+
* `<rootDir>/.planning/metrics/snapshots/<timestamp>.json`. Runs the rolling
|
|
154
|
+
* cleanup of files older than `retentionMs` (default 30d) on every call so
|
|
155
|
+
* callers don't need a separate retention job.
|
|
156
|
+
*
|
|
157
|
+
* The on-disk shape is `{ ts: <epoch_ms>, counters, latency }`. The `ts` field
|
|
158
|
+
* inside the JSON — NOT the filename — is the authoritative timestamp for
|
|
159
|
+
* loadSnapshots windowing. The filename uses an ISO encoding with `:` and `.`
|
|
160
|
+
* replaced by `-` for filesystem safety; that encoding is one-way (cannot
|
|
161
|
+
* round-trip back through Date.parse), so we never parse it for ordering.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} [rootDir=process.cwd()] Project root. Snapshots land under
|
|
164
|
+
* `<rootDir>/.planning/metrics/snapshots/`.
|
|
165
|
+
* @param {object} [opts]
|
|
166
|
+
* @param {number} [opts.retentionMs] Override the rolling-window age in ms.
|
|
167
|
+
* Defaults to 30 days. Tests use shorter windows to drive the cleanup path.
|
|
168
|
+
* @returns {Promise<{file: string, snap: {ts: number, counters: object, latency: object}}>}
|
|
169
|
+
*/
|
|
170
|
+
export async function persistSnapshot(rootDir = process.cwd(), opts = {}) {
|
|
171
|
+
const retentionMs = Number.isFinite(opts.retentionMs) ? opts.retentionMs : DEFAULT_RETENTION_MS;
|
|
172
|
+
const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
|
|
173
|
+
await fs.mkdir(dir, { recursive: true });
|
|
174
|
+
const ts = Date.now();
|
|
175
|
+
const snap = { ts, ...snapshot() };
|
|
176
|
+
// Filesystem-safe ISO encoding — Windows forbids `:` in paths and `.` is
|
|
177
|
+
// ambiguous with extension separators on shells with brace expansion.
|
|
178
|
+
const isoSafe = new Date(ts).toISOString().replace(/[:.]/g, '-');
|
|
179
|
+
const file = path.join(dir, `${isoSafe}.json`);
|
|
180
|
+
await fs.writeFile(file, JSON.stringify(snap, null, 2));
|
|
181
|
+
await cleanupOldSnapshots(dir, retentionMs);
|
|
182
|
+
return { file, snap };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* OBS-19-02 — Load all snapshots from disk whose in-file `ts` is within the
|
|
187
|
+
* sliding window. Returns the array sorted ascending by `ts` so consumers
|
|
188
|
+
* (`/burn-rate-status`) can compute first-vs-last deltas without re-sorting.
|
|
189
|
+
*
|
|
190
|
+
* Defensive against malformed JSON: a corrupt file is skipped silently rather
|
|
191
|
+
* than aborting the whole load. The 30d window is rolling from "now" — pass a
|
|
192
|
+
* smaller value to drive recent-only views (e.g. `60 * 60 * 1000` for last
|
|
193
|
+
* hour) when computing burn rate over a baseline window.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} [rootDir=process.cwd()] Project root.
|
|
196
|
+
* @param {number} [windowMs] Sliding window in ms. Defaults to 30 days.
|
|
197
|
+
* @returns {Promise<Array<{ts: number, counters: object, latency: object}>>}
|
|
198
|
+
* Empty array if the snapshots directory does not exist.
|
|
199
|
+
*/
|
|
200
|
+
export async function loadSnapshots(rootDir = process.cwd(), windowMs = DEFAULT_RETENTION_MS) {
|
|
201
|
+
const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
|
|
202
|
+
const cutoff = Date.now() - windowMs;
|
|
203
|
+
let files;
|
|
204
|
+
try {
|
|
205
|
+
files = await fs.readdir(dir);
|
|
206
|
+
} catch {
|
|
207
|
+
return []; // Dir absent on first run — not an error.
|
|
208
|
+
}
|
|
209
|
+
const results = [];
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
if (!f.endsWith('.json')) continue;
|
|
212
|
+
try {
|
|
213
|
+
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
214
|
+
const parsed = JSON.parse(raw);
|
|
215
|
+
if (Number.isFinite(parsed?.ts) && parsed.ts >= cutoff) {
|
|
216
|
+
results.push(parsed);
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Corrupt file — skip silently rather than break the whole burn-rate
|
|
220
|
+
// calculation. A future phase can surface counts via a doctor probe.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return results.sort((a, b) => a.ts - b.ts);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* OBS-19-03 — Internal helper: delete snapshot files older than `maxAgeMs`.
|
|
228
|
+
* Called from persistSnapshot on every write so retention is implicit.
|
|
229
|
+
* Uses fs.stat().mtimeMs as the age proxy; we accept the small drift versus
|
|
230
|
+
* the in-file `ts` because cleanup is best-effort eviction, not authoritative
|
|
231
|
+
* windowing (loadSnapshots reads the in-file ts).
|
|
232
|
+
*
|
|
233
|
+
* @param {string} dir Absolute path to the snapshots directory.
|
|
234
|
+
* @param {number} maxAgeMs Files with mtime older than this are unlinked.
|
|
235
|
+
* @returns {Promise<void>}
|
|
236
|
+
*/
|
|
237
|
+
async function cleanupOldSnapshots(dir, maxAgeMs) {
|
|
238
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
239
|
+
let files;
|
|
240
|
+
try {
|
|
241
|
+
files = await fs.readdir(dir);
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
for (const f of files) {
|
|
246
|
+
if (!f.endsWith('.json')) continue;
|
|
247
|
+
const fp = path.join(dir, f);
|
|
248
|
+
try {
|
|
249
|
+
const stat = await fs.stat(fp);
|
|
250
|
+
if (stat.mtimeMs < cutoff) await fs.unlink(fp);
|
|
251
|
+
} catch {
|
|
252
|
+
// Unlink can race with concurrent cleanup; ignore ENOENT and friends.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Boot-time reset honors KIT_MCP_METRICS_RESET=1. We call reset() instead of
|
|
258
|
+
// merely skipping init because the maps are already empty at module load —
|
|
259
|
+
// the call is a no-op today but documents the contract for any future module
|
|
260
|
+
// that imports metrics.js after another module has already populated state.
|
|
261
|
+
if (process.env.KIT_MCP_METRICS_RESET === '1') {
|
|
262
|
+
reset();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Exported for tests only — keeps the API surface explicit while letting unit
|
|
266
|
+
// tests assert on the FIFO behavior at the boundary.
|
|
267
|
+
export const __TEST_HISTOGRAM_CAP = HISTOGRAM_CAP;
|
|
268
|
+
export const __TEST_SNAPSHOT_DIR_REL = SNAPSHOT_DIR_REL;
|
package/src/core/notify.js
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
// src/core/notify.js — opt-in OS-level notification on MCP tool call.
|
|
2
|
-
//
|
|
3
|
-
// Phase 164 (v1.28). Off by default — set KIT_MCP_NOTIFY=1 to enable.
|
|
4
|
-
// Cross-platform best-effort: PowerShell BurntToast/MessageBox on Windows,
|
|
5
|
-
// osascript on macOS, notify-send on Linux. Failure is silent.
|
|
6
|
-
//
|
|
7
|
-
// Throttled: minimum 5s between notifications (configurable via
|
|
8
|
-
// KIT_MCP_NOTIFY_THROTTLE_MS) to prevent flood.
|
|
9
|
-
|
|
10
|
-
import { spawn } from 'node:child_process';
|
|
11
|
-
|
|
12
|
-
const DEFAULT_THROTTLE_MS = 5000;
|
|
13
|
-
let lastNotifyAt = 0;
|
|
14
|
-
|
|
15
|
-
function throttleMs() {
|
|
16
|
-
const raw = process.env.KIT_MCP_NOTIFY_THROTTLE_MS;
|
|
17
|
-
const n = parseInt(raw, 10);
|
|
18
|
-
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_THROTTLE_MS;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function isNotifyEnabled() {
|
|
22
|
-
return process.env.KIT_MCP_NOTIFY === '1' || process.env.KIT_MCP_NOTIFY === 'true';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function spawnDetached(cmd, args) {
|
|
26
|
-
try {
|
|
27
|
-
const c = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
28
|
-
c.unref();
|
|
29
|
-
} catch { /* swallow — notification must never break the server */ }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// notify({title, body}): fire a best-effort OS notification. Throttled.
|
|
33
|
-
export function notify({ title, body } = {}) {
|
|
34
|
-
if (!isNotifyEnabled()) return;
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
if (now - lastNotifyAt < throttleMs()) return;
|
|
37
|
-
lastNotifyAt = now;
|
|
38
|
-
|
|
39
|
-
const t = (title || 'kit-mcp').replace(/"/g, "'");
|
|
40
|
-
const b = (body || '').replace(/"/g, "'");
|
|
41
|
-
|
|
42
|
-
if (process.platform === 'darwin') {
|
|
43
|
-
spawnDetached('osascript', ['-e', `display notification "${b}" with title "${t}"`]);
|
|
44
|
-
} else if (process.platform === 'linux') {
|
|
45
|
-
spawnDetached('notify-send', [t, b]);
|
|
46
|
-
} else if (process.platform === 'win32') {
|
|
47
|
-
// PowerShell toast via Windows.UI.Notifications. Falls back silently if
|
|
48
|
-
// BurntToast not installed — we use the simpler msg via WScript.Shell.
|
|
49
|
-
const ps = `Add-Type -AssemblyName System.Windows.Forms; `
|
|
50
|
-
+ `$n = New-Object System.Windows.Forms.NotifyIcon; `
|
|
51
|
-
+ `$n.Icon = [System.Drawing.SystemIcons]::Information; `
|
|
52
|
-
+ `$n.BalloonTipTitle = "${t}"; `
|
|
53
|
-
+ `$n.BalloonTipText = "${b}"; `
|
|
54
|
-
+ `$n.Visible = $true; `
|
|
55
|
-
+ `$n.ShowBalloonTip(3000); `
|
|
56
|
-
+ `Start-Sleep -Seconds 4; `
|
|
57
|
-
+ `$n.Dispose()`;
|
|
58
|
-
spawnDetached('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
// src/core/notify.js — opt-in OS-level notification on MCP tool call.
|
|
2
|
+
//
|
|
3
|
+
// Phase 164 (v1.28). Off by default — set KIT_MCP_NOTIFY=1 to enable.
|
|
4
|
+
// Cross-platform best-effort: PowerShell BurntToast/MessageBox on Windows,
|
|
5
|
+
// osascript on macOS, notify-send on Linux. Failure is silent.
|
|
6
|
+
//
|
|
7
|
+
// Throttled: minimum 5s between notifications (configurable via
|
|
8
|
+
// KIT_MCP_NOTIFY_THROTTLE_MS) to prevent flood.
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_THROTTLE_MS = 5000;
|
|
13
|
+
let lastNotifyAt = 0;
|
|
14
|
+
|
|
15
|
+
function throttleMs() {
|
|
16
|
+
const raw = process.env.KIT_MCP_NOTIFY_THROTTLE_MS;
|
|
17
|
+
const n = parseInt(raw, 10);
|
|
18
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_THROTTLE_MS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isNotifyEnabled() {
|
|
22
|
+
return process.env.KIT_MCP_NOTIFY === '1' || process.env.KIT_MCP_NOTIFY === 'true';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function spawnDetached(cmd, args) {
|
|
26
|
+
try {
|
|
27
|
+
const c = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
28
|
+
c.unref();
|
|
29
|
+
} catch { /* swallow — notification must never break the server */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// notify({title, body}): fire a best-effort OS notification. Throttled.
|
|
33
|
+
export function notify({ title, body } = {}) {
|
|
34
|
+
if (!isNotifyEnabled()) return;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (now - lastNotifyAt < throttleMs()) return;
|
|
37
|
+
lastNotifyAt = now;
|
|
38
|
+
|
|
39
|
+
const t = (title || 'kit-mcp').replace(/"/g, "'");
|
|
40
|
+
const b = (body || '').replace(/"/g, "'");
|
|
41
|
+
|
|
42
|
+
if (process.platform === 'darwin') {
|
|
43
|
+
spawnDetached('osascript', ['-e', `display notification "${b}" with title "${t}"`]);
|
|
44
|
+
} else if (process.platform === 'linux') {
|
|
45
|
+
spawnDetached('notify-send', [t, b]);
|
|
46
|
+
} else if (process.platform === 'win32') {
|
|
47
|
+
// PowerShell toast via Windows.UI.Notifications. Falls back silently if
|
|
48
|
+
// BurntToast not installed — we use the simpler msg via WScript.Shell.
|
|
49
|
+
const ps = `Add-Type -AssemblyName System.Windows.Forms; `
|
|
50
|
+
+ `$n = New-Object System.Windows.Forms.NotifyIcon; `
|
|
51
|
+
+ `$n.Icon = [System.Drawing.SystemIcons]::Information; `
|
|
52
|
+
+ `$n.BalloonTipTitle = "${t}"; `
|
|
53
|
+
+ `$n.BalloonTipText = "${b}"; `
|
|
54
|
+
+ `$n.Visible = $true; `
|
|
55
|
+
+ `$n.ShowBalloonTip(3000); `
|
|
56
|
+
+ `Start-Sleep -Seconds 4; `
|
|
57
|
+
+ `$n.Dispose()`;
|
|
58
|
+
spawnDetached('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
59
|
+
}
|
|
60
|
+
}
|