diff --git a/packages/ghost/lib/city-client.mjs b/packages/ghost/lib/city-client.mjs
index 632117a..8d8dbb8 100644
--- a/packages/ghost/lib/city-client.mjs
+++ b/packages/ghost/lib/city-client.mjs
@@ -31,6 +31,7 @@ const ICON = {
file: icon(' '),
x: icon(' '),
undo: icon(' '),
+ copy: icon(' '),
};
const state = {
@@ -105,9 +106,21 @@ function normalize(raw) {
const anomaly = detect(samples);
let blamedPath = null;
if (anomaly) {
- const cp = checkpoints.filter((c) => c.turnNumber === anomaly.turn);
+ // Probes sample every few seconds, so a spike is often first seen one turn
+ // after the write that caused it. Blame the window [turn-1, turn], weighted
+ // so the current turn still wins a tie — otherwise the fix prompt points at
+ // whichever stylesheet happened to be edited next.
let best = null;
- for (const c of cp) for (const [p, v] of Object.entries(c.churn || {})) if (!best || v > best.v) best = { p, v };
+ for (const turn of [anomaly.turn, anomaly.turn - 1]) {
+ const weight = turn === anomaly.turn ? 1 : 0.7;
+ for (const c of checkpoints) {
+ if (c.turnNumber !== turn) continue;
+ for (const [p, v] of Object.entries(c.churn || {})) {
+ const score = v * weight;
+ if (!best || score > best.score) best = { p, score };
+ }
+ }
+ }
blamedPath = best ? best.p : null;
}
@@ -254,39 +267,123 @@ function chartSvg(samples) {
if (!samples.length) {
return '
' + GHOST(24, "ghost-mark--glow") + "
0 samples yet — point the recorder at your app (--app-url) so health samples arrive.
";
}
- const w = 320, h = 92, pad = 8;
- const rss = samples.map((s) => (s.metrics || {}).rss || 0);
- const cpu = samples.map((s) => (s.metrics || {}).cpu || 0);
- const maxR = Math.max(...rss, 0);
- const maxC = Math.max(...cpu, 0);
- if (maxR <= 0 && maxC <= 0) {
- return '' + GHOST(24, "ghost-mark--glow") + "
the probe answered, but returned no rss/cpu numbers yet — check /api/health on your app.
";
- }
- const scaleR = Math.max(maxR, 1);
- const scaleC = Math.max(maxC, 1);
- const x = (i) => pad + (i / Math.max(1, samples.length - 1)) * (w - pad * 2);
- const yR = (v) => h - pad - (v / scaleR) * (h - pad * 2);
- const yC = (v) => h - pad - (v / scaleC) * (h - pad * 2);
- const line = (arr, y) => arr.map((v, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(v).toFixed(1)).join(" ");
- const threshold = yR(scaleR * 0.82);
+ const W = 560, H = 250, padL = 44, padR = 46, padT = 30, padB = 30;
+ const mem = samples.map((s) => Math.round(((s.metrics || {}).rss || 0) / 1048576));
+ const lat = samples.map((s) => Math.round((s.metrics || {}).latencyMs || 0));
+ const maxMem = Math.max(400, Math.max.apply(null, mem)) * 1.02;
+ const maxLat = Math.max(300, Math.max.apply(null, lat)) * 1.08;
+ const threshold = 200;
+ const x = (i) => padL + (i / Math.max(1, samples.length - 1)) * (W - padL - padR);
+ const yMem = (v) => H - padB - (v / maxMem) * (H - padT - padB);
+ const yLat = (v) => H - padB - (v / maxLat) * (H - padT - padB);
+ const smooth = (pts) => {
+ if (!pts.length) return "";
+ let d = "M " + pts[0][0].toFixed(1) + " " + pts[0][1].toFixed(1);
+ for (let i = 0; i < pts.length - 1; i += 1) {
+ const mid = (pts[i][0] + pts[i + 1][0]) / 2;
+ d += " C " + mid.toFixed(1) + " " + pts[i][1].toFixed(1) + ", " + mid.toFixed(1) + " " + pts[i + 1][1].toFixed(1) + ", " + pts[i + 1][0].toFixed(1) + " " + pts[i + 1][1].toFixed(1);
+ }
+ return d;
+ };
+ const memPath = smooth(mem.map((v, i) => [x(i), yMem(v)]));
+ const latPath = smooth(lat.map((v, i) => [x(i), yLat(v)]));
+ const areaPath = memPath + " L " + x(samples.length - 1).toFixed(1) + " " + (H - padB) + " L " + x(0).toFixed(1) + " " + (H - padB) + " Z";
+
+ const anomalyIdx = state.anomaly ? Math.min(samples.length - 1, state.anomaly.index) : -1;
+ const anomalyEnd = anomalyIdx >= 0 ? Math.min(samples.length - 1, anomalyIdx + 4) : -1;
+ const delta = anomalyIdx > 0 ? mem[anomalyIdx] - mem[anomalyIdx - 1] : 0;
const activeN = state.turns[state.active] ? state.turns[state.active].n : 0;
- let activeIdx = -1;
+ let activeIdx = 0;
samples.forEach((s, i) => { if ((s.turnNumber || 0) <= activeN) activeIdx = i; });
+
+ const grid = [0, 0.25, 0.5, 0.75, 1].map((f) => {
+ const value = Math.round(maxMem * f);
+ const y = yMem(value);
+ return ' ' +
+ '' + value + " ";
+ }).join("");
+ const right = [0, 0.5, 1].map((f) => {
+ const value = Math.round(maxLat * f);
+ return '' + value + " ";
+ }).join("");
+
+ const region = anomalyIdx >= 0
+ ? ' ' +
+ ' '
+ : "";
+
+ const leader = anomalyIdx > 0
+ ? ' ' +
+ '+' + delta + " MB / sample "
+ : "";
+
return '' +
- ' rss ' +
- ' cpu ' +
+ ' rss (MB) ' +
+ ' p95 (ms) ' +
' threshold ' +
- '' + samples.length + " samples
" +
- '' +
- ' ' +
- (activeIdx >= 0 ? ' ' : "") +
- ' ' +
- ' ' +
- '' + Math.round(scaleR / 1024 / 1024) + " MB peak " +
- '' + Math.round(scaleC) + "% cpu " +
+ "" +
+ '' +
+ ' ' +
+ ' ' +
+ region + grid + right +
+ ' ' +
+ 'threshold ' + threshold + " MB " +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ 't' + activeN + " " +
+ leader +
+ ' ' +
+ ' ' +
" ";
}
+function bindChart() {
+ const hit = $("chartHit");
+ const layer = $("chartHover");
+ if (!hit || !layer) return;
+ const W = 560, H = 250, padL = 44, padR = 46, padT = 30, padB = 30;
+ const samples = state.samples;
+ const mem = samples.map((s) => Math.round(((s.metrics || {}).rss || 0) / 1048576));
+ const lat = samples.map((s) => Math.round((s.metrics || {}).latencyMs || 0));
+ const maxMem = Math.max(400, Math.max.apply(null, mem)) * 1.02;
+ const maxLat = Math.max(300, Math.max.apply(null, lat)) * 1.08;
+ const x = (i) => padL + (i / Math.max(1, samples.length - 1)) * (W - padL - padR);
+ const yMem = (v) => H - padB - (v / maxMem) * (H - padT - padB);
+ const yLat = (v) => H - padB - (v / maxLat) * (H - padT - padB);
+
+ const indexAt = (event) => {
+ const rect = hit.ownerSVGElement.getBoundingClientRect();
+ const local = ((event.clientX - rect.left) / rect.width) * W;
+ const ratio = (local - padL) / (W - padL - padR);
+ return Math.max(0, Math.min(samples.length - 1, Math.round(ratio * (samples.length - 1))));
+ };
+
+ hit.addEventListener("pointermove", (event) => {
+ const i = indexAt(event);
+ const s = samples[i];
+ const tooltipW = 156;
+ const tx = Math.min(W - tooltipW - 6, Math.max(6, x(i) - tooltipW / 2));
+ const metrics = s.metrics || {};
+ layer.innerHTML =
+ ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ ' ' +
+ 't' + (s.turnNumber || 0) + " · " + mem[i] + " MB " +
+ '' + lat[i] + " ms p95 · " + Math.round(metrics.cpu || 0) + "% cpu " +
+ '' + (metrics.errors || 0) + " errors ";
+ });
+ hit.addEventListener("pointerleave", () => { layer.innerHTML = ""; });
+ hit.addEventListener("click", (event) => {
+ const s = samples[indexAt(event)];
+ const idx = state.turns.findIndex((t) => t.n === (s.turnNumber || 0));
+ if (idx >= 0) selectTurn(idx);
+ });
+}
+
function renderAutopsy() {
const turn = state.turns[state.active] || null;
const anomaly = state.anomaly;
@@ -349,13 +446,36 @@ function renderAutopsy() {
(turn.sha ? 'checkpoint ' + esc(String(turn.sha).slice(0, 10)) + ' ' : 'No patch for this turn — the recorder only stores diffs when git shadow checkpoints exist.
') + "";
}
- html += 'Runtime ' + state.samples.length + " samples
" + chartSvg(state.samples) + "
";
+ html += 'Runtime metrics ' + state.samples.length + " samples
" + chartSvg(state.samples) + "
";
+
+ const fixPrompt = state.anomaly
+ ? "Add a bounded LRU eviction (e.g. clear or shift when size exceeds a cap) to " + (state.blamedPath || "the hottest module") + " and re-run the memory regression check."
+ : "No anomaly detected — nothing to heal yet. Keep recording; the engine drafts the heal prompt if a sample breaches a threshold.";
+ html += 'Fix prompt ' +
+ (state.anomaly ? '' + ICON.copy + "Copy " : "") +
+ '
' + esc(fixPrompt) + "
";
$("autopsy").innerHTML = html;
const jump = $("jumpBlame");
if (jump) jump.onclick = () => { const i = state.turns.findIndex((t) => t.n === (state.anomaly ? state.anomaly.turn : 0)); if (i >= 0) selectTurn(i); state.preset = "blame"; renderAll(); };
const clear = $("clearSel");
if (clear) clear.onclick = () => { state.selected = null; renderAll(); };
+ const copyFix = $("copyFix");
+ if (copyFix) copyFix.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(fixPrompt);
+ copyFix.dataset.copied = "true";
+ copyFix.innerHTML = ICON.check + "Copied ";
+ setTimeout(() => {
+ if (!copyFix.isConnected) return;
+ copyFix.dataset.copied = "false";
+ copyFix.innerHTML = ICON.copy + "Copy ";
+ }, 1800);
+ } catch {
+ copyFix.dataset.copied = "false";
+ }
+ };
+ bindChart();
const viewPatch = $("viewPatch");
if (viewPatch) viewPatch.onclick = () => {
const d = document.querySelector(".pg-details");
@@ -630,7 +750,7 @@ function overviewDist() {
const ext = state.towers.length
? Math.max(...state.towers.map((t) => Math.max(Math.abs(t.x), Math.abs(t.z))))
: 12;
- return clamp(ext * 2.4 + 16, 36, 86);
+ return clamp(ext * 3.0 + 20, 44, 100);
}
function pick(e) {
diff --git a/packages/ghost/lib/city.css b/packages/ghost/lib/city.css
index eb4f725..a5515ae 100644
--- a/packages/ghost/lib/city.css
+++ b/packages/ghost/lib/city.css
@@ -1582,3 +1582,34 @@ canvas{display:block;max-width:100%}
}
@media (min-width:901px){.pg-stage__empty p{max-width:420px}}
+.copy-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 8px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--fg-2);
+ font-family: var(--font-mono-stack);
+ font-size: 9px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ transition: color .25s, border-color .25s, background-color .25s;
+}
+
+.copy-btn:hover {
+ color: var(--fg);
+ border-color: var(--line-strong);
+ background: rgba(255, 255, 255, .04);
+}
+
+.copy-btn[data-copied="true"] {
+ color: var(--emerald);
+ border-color: rgba(94, 234, 212, .28);
+}
+
+.copy-btn svg {
+ width: 12px;
+ height: 12px;
+}
diff --git a/packages/ghost/lib/probes.mjs b/packages/ghost/lib/probes.mjs
index ad707e4..5cbd342 100644
--- a/packages/ghost/lib/probes.mjs
+++ b/packages/ghost/lib/probes.mjs
@@ -11,8 +11,10 @@ export function createProbes({ url, intervalMs = 4000, fetchImpl = globalThis.fe
async function sample() {
const ts = new Date().toISOString();
try {
+ const started = Date.now();
const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5000) });
- if (!res.ok) return push(ts, { errors: 1, httpStatus: res.status }, true);
+ const latencyMs = Date.now() - started;
+ if (!res.ok) return push(ts, { errors: 1, httpStatus: res.status, latencyMs }, true);
const body = await res.json().catch(() => ({}));
const cpu = Number(body.cpuPercent ?? body.cpu ?? 0) || 0;
let cpuDelta = cpu;
@@ -22,7 +24,7 @@ export function createProbes({ url, intervalMs = 4000, fetchImpl = globalThis.fe
cpu: Number((cpuDelta * 100 + cpu).toFixed(2)),
rss: Number(body.rss ?? 0),
heap: Number(body.heap ?? 0),
- latencyMs: null,
+ latencyMs,
errors: 0,
uptime: Number(body.uptime ?? 0),
}, false);
diff --git a/packages/ghost/lib/shadowgit.mjs b/packages/ghost/lib/shadowgit.mjs
index 6fead45..ed8bd9f 100644
--- a/packages/ghost/lib/shadowgit.mjs
+++ b/packages/ghost/lib/shadowgit.mjs
@@ -17,26 +17,50 @@ const IGNORE_SET = new Set([
* diff is a unified diff patch versus HEAD ("" when the worktree matches HEAD).
*/
export function commitSnapshot(repo, message = "ghost checkpoint") {
- const idx = path.join(repo, ".ghost", "shadow-index");
+ // One index file per recorder process. Two recorders watching the same repo
+ // (a leftover demo run + a manual `ghost watch`, say) would otherwise fight
+ // over a single .ghost/shadow-index.lock, and every `git add` after the first
+ // would die with "File exists" — the city silently froze at a couple of towers.
+ const idx = path.join(repo, ".ghost", `shadow-index-${process.pid}`);
fs.mkdirSync(path.dirname(idx), { recursive: true });
+ // A killed recorder leaves its lock behind; this pid is ours now, so anything
+ // left under this exact name is dead weight.
+ for (const stale of [idx, `${idx}.lock`]) {
+ try { fs.unlinkSync(stale); } catch { /* nothing to clean */ }
+ }
const env = { ...process.env, GIT_INDEX_FILE: idx };
const git = (args, extraEnv = {}) =>
- execFileSync("git", args, { cwd: repo, env: { ...env, ...extraEnv }, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
+ execFileSync("git", args, {
+ cwd: repo,
+ env: { ...env, ...extraEnv },
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ maxBuffer: 1024 * 1024 * 64,
+ }).trim();
+ // execFileSync only exposes stderr when we ask for it; without this the CLI
+ // printed a bare "Command failed: git add -A -- ." and hid the real reason.
+ const run = (args, extraEnv) => {
+ try { return git(args, extraEnv); }
+ catch (e) {
+ const detail = String(e.stderr || "").trim().split(/\r?\n/).filter(Boolean).pop();
+ throw new Error(`git ${args[0]} failed: ${detail || e.message}`);
+ }
+ };
- try { git(["read-tree", "HEAD"]); } catch { /* empty repo without HEAD */ }
+ try { run(["read-tree", "HEAD"]); } catch { /* empty repo without HEAD */ }
// .ghost is the recorder's own bookkeeping (shadow index + patches). Staging
// it would grow every snapshot by megabytes and eventually blow maxBuffer —
// the city would silently stop growing. Add it to the repo-local exclude
// (never committed, never touches the user's .gitignore) so a plain add
// skips it everywhere, including repos that do not ignore .ghost.
ensureExcluded(repo);
- git(["add", "-A", "--", "."]);
- const tree = git(["write-tree"]);
+ run(["add", "-A", "--", "."]);
+ const tree = run(["write-tree"]);
let parent = null;
- try { parent = git(["rev-parse", "HEAD"]); } catch { /* first snapshot */ }
+ try { parent = run(["rev-parse", "HEAD"]); } catch { /* first snapshot */ }
const commitArgs = parent ? ["commit-tree", tree, "-p", parent, "-m", message] : ["commit-tree", tree, "-m", message];
- const sha = git(commitArgs, { GIT_AUTHOR_DATE: new Date().toISOString(), GIT_COMMITTER_DATE: new Date().toISOString() });
+ const sha = run(commitArgs, { GIT_AUTHOR_DATE: new Date().toISOString(), GIT_COMMITTER_DATE: new Date().toISOString() });
const diffOut = execFileSync(
"git", ["diff", "--binary", ...(parent ? [parent] : ["4b825dc642cb6eb9a060e54bf8d69288fbee4904"]), sha],
{ cwd: repo, encoding: "utf8", maxBuffer: 1024 * 1024 * 64 },