Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,18 @@ ghost sessions # list sessions stored in .ghost
ghost setup # optional: pair the device with Ghost Cloud (metadata-only sync; source code never leaves the machine)
```

While `ghost watch` runs it also opens **Ghost Studio** in your browser —
`http://127.0.0.1:5417` (the recorder's own zero-dependency page): the live
session shows the repo, the real turns (prompts), the files being changed with
per-file churn, and the runtime samples from your app's `/api/health`. Data
never leaves the machine.

For the full 3D playground (the same interface as the published demo, fed by
YOUR recording instead of the fixture): run this repo's dashboard locally
(`npm run dev`) and open [`/playground?live=1`](http://localhost:3000/playground?live=1).
It streams the live recording through a loopback bridge — the cloud playground
sees sessions only after explicit pairing (`ghost setup`), never raw disks.

**Using OpenCode?** (Mode A — real turn ids, richest context) copy the one-file
adapter into your project, then start `ghost watch` and use OpenCode as usual:

Expand Down
31 changes: 31 additions & 0 deletions app/api/ghost/snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Loopback bridge to the local Ghost recorder (packages/ghost ghost watch).
// Works only while the dashboard runs on the same machine (`npm run dev`):
// the recorder serves the live session JSON on 127.0.0.1:<port>/session.
// On a hosted deployment this responds as "recorder: not reachable".
import { NextResponse } from "next/server";

const RECORDER =
process.env.GHOST_RECORDER_URL ?? "http://127.0.0.1:5417";

export const dynamic = "force-dynamic";

export async function GET() {
try {
const res = await fetch(`${RECORDER}/session`, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(1500),
cache: "no-store",
});
if (!res.ok) throw new Error(`recorder ${res.status}`);
const session = await res.json();
return NextResponse.json(
{ recorder: true, session },
{ headers: { "cache-control": "no-store" } },
);
} catch {
return NextResponse.json(
{ recorder: false, reason: "no local ghost watch is running on this machine" },
{ headers: { "cache-control": "no-store" } },
);
}
}
16 changes: 15 additions & 1 deletion app/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { demoSession } from "@/src/data/fixture";
import Dashboard from "@/src/components/Dashboard";
import LiveDashboard from "@/src/components/playground/LiveDashboard";

export default function Page() {
export default function Page({
searchParams,
}: {
searchParams?: { live?: string };
}) {
if (searchParams?.live) {
// ?live=1 — stream the REAL recording (`ghost watch`) instead of the
// committed demo session. Same Dashboard, same 3D city, real data.
return (
<main className="app">
<LiveDashboard />
</main>
);
}
return (
<main className="app">
<Dashboard session={demoSession} />
Expand Down
129 changes: 128 additions & 1 deletion packages/ghost/ghost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,91 @@ const ROOT = process.cwd();
const GHOST_DIR = path.join(ROOT, ".ghost");
const DEFAULT_RECORDER_PORT = 5417;

// ── Ghost Studio (served by the recorder on 127.0.0.1:<port>) ────────────────
// Zero-dependency live view of the session being recorded: repo, turns,
// checkpoints (files + diff refs), runtime samples. Data source: GET /session.
function studioHtml(base) {
return `<!doctype html><html><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Ghost Studio — live recording</title>
<style>
:root{--bg:#040a08;--fg:#e8f0ee;--dim:#9fb5af;--line:rgba(255,255,255,.09);--hot:#ff7a6b;--cold:#5eead4;--card:rgba(255,255,255,.04)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.wrap{max-width:860px;margin:0 auto;padding:28px 20px 60px}
.top{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.dot{width:8px;height:8px;border-radius:50%;background:var(--cold);box-shadow:0 0 8px var(--cold);animation:pulse 2s infinite}
@keyframes pulse{50%{opacity:.35}}
h1{font-size:17px;margin:0;letter-spacing:.02em}
sub{color:var(--dim);display:block;margin:10px 0 22px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:26px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.stat b{display:block;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.stat i{font-style:normal;color:var(--dim);font-size:11px;letter-spacing:.08em;text-transform:uppercase}
h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:26px 0 10px}
.empty{color:var(--dim);padding:14px;border:1px dashed var(--line);border-radius:10px}
ul{list-style:none;margin:0;padding:0}
.turn{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:11px 14px;margin-bottom:8px}
.turn b{color:var(--dim);margin-right:8px}
.est{display:inline-block;font-size:10px;color:var(--hot);border:1px solid var(--hot);border-radius:99px;padding:0 7px;margin-right:8px}
.file{display:block;color:var(--cold);font-size:12px;margin-top:4px;word-break:break-all}
.prompt{white-space:pre-wrap;word-break:break-word}
table{width:100%;border-collapse:collapse;font-size:12px}
td,th{border-bottom:1px solid var(--line);padding:6px 8px;text-align:right}
td:first-child,th:first-child{text-align:left}
th{color:var(--dim);font-weight:400;letter-spacing:.08em;text-transform:uppercase;font-size:10px}
.hotm{color:var(--hot)}
footer{margin-top:28px;color:var(--dim);font-size:11px;line-height:1.8}
a{color:var(--cold)}
</style></head><body><div class="wrap">
<div class="top"><span class="dot"></span><h1>Ghost Studio — recording live</h1></div>
<sub>same host as your recorder · loopback only · data stays in this machine</sub>
<div class="grid">
<div class="stat"><i>repo</i><b id="repo">…</b></div>
<div class="stat"><i>session</i><b id="sid">…</b></div>
<div class="stat"><i>git head</i><b id="head">…</b></div>
<div class="stat"><i>app</i><b id="app">…</b></div>
</div>
<h2>Turns <span id="tcount"></span></h2><ul id="turns"></ul>
<h2>Checkpoints — shadow commits</h2><div id="cps"></div>
<h2>Runtime samples</h2><div id="samples"></div>
<footer>Recorder keeps saving to <span id="dref">.ghost/sessions/…</span> · Ctrl+C stops &amp; saves ·<br/>
locals: <a href="/session">/session</a> (JSON) · events target for the OpenCode adapter: <a href="#">${base}/events</a></footer>
</div><script>
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
async function tick(){
try{const s=await(top?0:0,0)||await fetch("/session").then(r=>r.json());
repo.textContent=s.repo;sid.textContent=s.id;head.textContent=s.head?s.head.slice(0,10):"(no git)";app.textContent=s.appUrl;
dref.textContent=".ghost/sessions/"+s.id+"/";
tcount.textContent="("+(s.turns??.length)+" recorded)";
turns.innerHTML=(s.turns??[]).map(t=>"<li class='turn'><b>t"+t.index+"</b>"+(t.estimated?"<span class='est'>estimated</span>":"")+
"<span class='prompt'>"+esc(t.prompt??(t.id?"[assistant turn — "+t.id+"]":"[no prompt captured]"))+"</span>"+
(t.files??[]).slice(0,8).map(f=>"<span class='file'>"+esc(f)+"</span>").join("")+
(t.startedAt?" <span style='color:var(--dim);font-size:11px'>"+new Date(t.startedAt).toLocaleTimeString()+"</span>":"")+"</li>").join("")||"<div class='empty'>No turns yet — make an edit or let your agent work; turn 1 shows within ~1s.</div>";
cps.innerHTML=(s.checkpoints??[]).map(c=>"<div class='turn'><b>"+c.sha.slice(0,10)+"</b> <span style='color:var(--dim);font-size:11px'>turn "+c.turnNumber+" · "+new Date(c.ts).toLocaleTimeString()+"</span>"+(c.files??[]).map(f=>"<span class='file'>"+esc(f)+"</span>").join("")+"</div>").join("")||"<div class='empty'>No checkpoints yet — a burst of file changes tips them out.</div>";
const sm=s.samples??[];
samples.innerHTML=sm.length?"<table><tr><th>ts</th><th>turn</th><th>rss</th><th>cpu</th><th>state</th></tr>"+
sm.slice(-12).map(x=>"<tr><td>"+esc(x.ts?.slice(11,19))+"</td><td>t"+x.turnNumber+"</td><td>"+(x.metrics?.rss??"—")+"</td><td>"+(x.metrics?.cpu??"—")+"</td><td>"+(x.probeFailed?"<span class='hotm'>probe failed</span>":"ok")+"</td></tr>").join("")+
"</table><div class='empty'>"+sm.length+" samples counted · saved with the session"+"</div>"
:"<div class='empty'>No samples yet — is your app running? the recorder probes "+esc(s.appUrl||"http://localhost:3000")+".</div>";
}catch(e){document.title="ghost studio…retrying"}}
tick();setInterval(tick,2000);
</script></body></html>`;
}

// ── helpers ──────────────────────────────────────────────────────────────────
// Parse a unified diff into per-file churn (added+deleted lines) used as the
// 3D city "height" — so towers stay a fact of the recording, never invented.
function parseDiffChurn(diffText) {
const churn = {};
let current = null;
for (const line of String(diffText).split(/\r?\n/)) {
const m = /^\+\+\+ b\/(.+)$/.exec(line);
if (m) { current = m[1]; if (!churn[current]) churn[current] = 0; continue; }
if (line.startsWith("diff --git ")) { current = null; continue; }
if (current && (line.startsWith("+") || line.startsWith("-")) && !line.startsWith("+++") && !line.startsWith("---")) churn[current] += 1;
}
return churn;
}
const readConfig = () => {
try { return JSON.parse(fs.readFileSync(path.join(GHOST_DIR, "config.json"), "utf8")); }
catch { return { cloudUrl: "https://ghostinthecodebase.vercel.app", sync: "off", recorderPort: DEFAULT_RECORDER_PORT }; }
Expand Down Expand Up @@ -122,6 +206,33 @@ async function cmdWatch() {
const port = Number(arg("--recorder-port", config.recorderPort || DEFAULT_RECORDER_PORT));
const eventsBase = `http://127.0.0.1:${port}`;
const emitter = http.createServer((req, res) => {
if (req.method === "GET" && (req.url === "/" || req.url === "/studio")) {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(studioHtml(eventsBase));
return;
}
if (req.method === "GET" && req.url === "/session") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify(session));
return;
}
if (req.method === "GET" && req.url.startsWith("/diff/")) {
const name = decodeURIComponent(req.url.slice(6).split("?")[0]);
const dir = store.sessionDir(ROOT, session);
try {
const file = fs.readdirSync(path.join(dir, "diffs")).find((f) => f.startsWith(name));
if (!file) throw new Error("not found");
res.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
"access-control-allow-origin": "*",
});
res.end(fs.readFileSync(path.join(dir, "diffs", file), "utf8"));
} catch { res.writeHead(404).end(); return; }
return;
}
if (req.method !== "POST" || req.url !== "/events") { res.writeHead(404).end(); return; }
let body = "";
req.on("data", (c) => (body += c));
Expand All @@ -144,16 +255,30 @@ async function cmdWatch() {
});
await new Promise((r) => emitter.listen(port, "127.0.0.1", r));

// open Ghost Studio in the default browser (best effort; --no-open to skip)
if (!arg("--no-open", false)) {
try {
if (process.platform === "win32") spawn("cmd", ["/c", "start", "", `${eventsBase}/studio`], { detached: true, stdio: "ignore" }).unref();
else spawn(process.platform === "darwin" ? "open" : "xdg-open", [`${eventsBase}/studio`], { detached: true, stdio: "ignore" }).unref();
} catch { /* headless machine — the banner still shows the URL */ }
}

// Mode B — filesystem watcher (works with ANY agent, no events needed)
let burst = null;
const checkpoint = (label) => {
try {
const snap = shadow.commitSnapshot(repo, `ghost: ${label}`);
if (!snap.diff) return;
// recorder internals (.ghost/*) are bookkeeping, not app files — they
// must never become towers in the city.
const isNoise = (p) => p.startsWith(".ghost/") || p === ".ghost";
const changedFiles = snap.changedFiles.filter((p) => !isNoise(p));
const churnEntries = Object.entries(parseDiffChurn(snap.diff)).filter(([p]) => !isNoise(p));
if (!changedFiles.length || !churnEntries.length) return;
const dir = store.sessionDir(ROOT, session);
fs.mkdirSync(path.join(dir, "diffs"), { recursive: true });
fs.writeFileSync(path.join(dir, "diffs", `${snap.sha.slice(0, 12)}.patch`), snap.diff);
session.checkpoints.push({ ts: new Date().toISOString(), turnNumber: currentTurn(), sha: snap.sha, files: snap.changedFiles, diffRef: `${session.id}/diffs/${snap.sha.slice(0, 12)}.patch` });
session.checkpoints.push({ ts: new Date().toISOString(), turnNumber: currentTurn(), sha: snap.sha, files: changedFiles, diffRef: `${session.id}/diffs/${snap.sha.slice(0, 12)}.patch`, churn: Object.fromEntries(churnEntries) });
const t = activeTurn(); // attribute checkpoint against the open turn
} catch (e) { console.error(" ! checkpoint failed:", e.message); }
};
Expand All @@ -177,6 +302,8 @@ async function cmdWatch() {
console.log(` repo ${repo}`);
if (!session.head) console.log(` git none — file recording only (run git init to enable shadow-git checkpoints)`);
console.log(` app ${appUrl}`);
console.log(` studio ${eventsBase} ← open in browser: live turns, checkpoints, samples`);
console.log(` replay local dashboard: /playground?live=1 (identical to the published demo)`);
console.log(` adapter ${eventsBase}/events (OpenCode plugin target)`);
console.log(` sync ${sync}${sync === "off" ? " (local-only) — run: ghost setup to enable" : ""}`);
console.log(` press Ctrl+C to stop & save\n`);
Expand Down
87 changes: 72 additions & 15 deletions src/autopsy/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,93 @@ const THRESHOLDS = {
};

export function detectAnomalies(metrics: MetricSample[]): Anomaly[] {
// matcha:todo real detection (trend + threshold). Placeholder: flag any
// sample breaching a hard threshold and attribute to its turn.
// First-breach detection over the hard thresholds, deduped per kind: the
// goal is the ONSET turn of a real breach, not an alert storm. The
// trend-slope detector is the next layer (PRD_CONCEPT.md); for live
// recordings the onset turn is already an actionable, honest verdict.
// Convention: metric samples carry their recorder turn number verbatim
// (1-based) and turn ids are "t<turnNumber>" — the adapter re-resolves.
const out: Anomaly[] = [];
const seen = new Set<string>();
const now = () => new Date().toISOString();
for (const m of metrics) {
if (m.mem > THRESHOLDS.memMb) {
if (m.mem > THRESHOLDS.memMb && !seen.has("leak")) {
seen.add("leak");
out.push({
id: `anom-mem-${m.turn}`,
id: `anom-mem-t${m.turn}`,
kind: "leak",
turnId: `t${m.turn + 1}`,
turnId: `t${m.turn}`,
severity: "high",
metric: "mem",
detectedAt: new Date().toISOString(),
note: `Memory exceeded ${THRESHOLDS.memMb} MB.`,
detectedAt: now(),
note: `Memory breached ${THRESHOLDS.memMb} MB (first sample: ${m.mem} MB).`,
});
}
if (m.cpu > THRESHOLDS.cpuPct && !seen.has("cpu-spin")) {
seen.add("cpu-spin");
out.push({
id: `anom-cpu-t${m.turn}`,
kind: "cpu-spin",
turnId: `t${m.turn}`,
severity: "med",
metric: "cpu",
detectedAt: now(),
note: `CPU breached ${THRESHOLDS.cpuPct}% (${m.cpu}%).`,
});
}
if (m.errors > THRESHOLDS.errors && !seen.has("error-spike")) {
seen.add("error-spike");
out.push({
id: `anom-err-t${m.turn}`,
kind: "error-spike",
turnId: `t${m.turn}`,
severity: "high",
metric: "errors",
detectedAt: now(),
note: `${m.errors} errors in one sample window.`,
});
}
if (m.latency > THRESHOLDS.latencyMs && !seen.has("hang")) {
seen.add("hang");
out.push({
id: `anom-lat-t${m.turn}`,
kind: "hang",
turnId: `t${m.turn}`,
severity: "med",
metric: "latency",
detectedAt: now(),
note: `Latency breached ${THRESHOLDS.latencyMs} ms p95 (${m.latency} ms).`,
});
}
}
return out;
}

export function buildAutopsy(
_anomaly: Anomaly,
_turns: Turn[],
anomaly: Anomaly,
turns: Turn[],
): Autopsy {
// matcha:todo attribution bisect -> blamedTurnId + generated fixPrompt.
// Attribution, honest edition: global turn ids may differ from the recording
// (plugin ids / estimated turns), so attach the real turn — if present — and
// ground the verdict in what the recording actually shows: the onset sample
// plus the files that turn touched. Never claim more causality than that.
const blamedTurn =
turns.find((t) => t.id === anomaly.turnId) ??
turns.find((t) => `t${t.index}` === anomaly.turnId) ??
null;
const files = blamedTurn?.changedFiles ?? [];
const rootCause = files.length
? `${anomaly.note} Recorded while this turn was in flight (it touched: ${files.join(", ")}). The recording, not a causal claim.`
: anomaly.note;
const fixPrompt = files.length
? `Roll back the files touched by this turn (${files.join(", ")}) to their pre-turn state, re-run ghost watch, and confirm the curve flattens.`
: `Re-run ghost watch around a checkpoint and compare the curve across the burst.`;
return {
id: "aut-stub",
anomalyId: _anomaly.id,
blamedTurnId: _anomaly.turnId,
rootCause: "Stub autopsy — wire attribution in the full build.",
fixPrompt: "Stub fix prompt.",
id: `aut-${anomaly.id}`,
anomalyId: anomaly.id,
blamedTurnId: anomaly.turnId,
rootCause,
fixPrompt,
healed: false,
};
}
Expand Down
Loading
Loading