Rework the agent surface: commands, saved scripts, handshake instructions, dashboard; drop flows and chat

State changes (from the maat-agent spikes):
- run_script runs saved scripts by path with args and params (PARAM_<NAME>
  env vars), plus ad-hoc code; results start with a status header and a
  missing-package hint points at connection.yaml deps
- commands/ folders in connections and modules register as MCP prompts
  (<owner>__<command>), surfaced as slash commands; .md and .py formats
- instructions.md is pushed at connect through the MCP handshake and
  declared as ledger pipe G0: one controllable file is what the agent
  receives at start
- read_context/write_context renamed to read_file/write_file; new list_dir
  and grep tools
- stateless HTTP: server restarts no longer strand attached clients

Removed:
- flows (never met a real use case; modules plus commands cover process
  needs; design.md records the return condition)
- gcontext chat (redundant once the handshake delivers instructions); the
  controlled claude invocation is documented in the README instead
- docs/templates (duplicated README sections)
- the ledger's dual chat/mcp mode, collapsed to one

Structure:
- server.py is only the MCP surface; concerns split into fs.py, exec.py,
  secrets.py, state.py, ledger.py, commands.py; agent-facing tool text
  lives in prompts/tools/*.md
- read-only web dashboard served at the root: overview, ledger, files,
  live activity feed (web/ Vite app, bundled into the wheel)
- secrets.env is now unreadable through the agent (read guard)

39 tests. Version 0.4.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-08-01 12:08:51 +02:00
co-authored by Claude Fable 5
parent 0fcabc2614
commit 730b02197a
54 changed files with 5614 additions and 924 deletions
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gcontext</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/icon-light-32x32.png" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/png" sizes="32x32" href="/icon-dark-32x32.png" media="(prefers-color-scheme: dark)" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3424
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "gcontext-dashboard",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "^11.11.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^6.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+82
View File
@@ -0,0 +1,82 @@
import React, { useEffect, useRef, useState } from "react";
import { getJSON } from "./lib.js";
import { C, mono, label } from "./ui.jsx";
// Activity = what crossed from gcontext into the agent, newest first.
// A flat feed from /api/events (in-memory ring buffer, empties on restart);
// a `connect` event starts a session, drawn as a separator. Click a row to
// expand the recorded preview inline. Polls every 3s while visible.
const fmtTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
function dayLabel(ts) {
const d = new Date(ts), now = new Date();
const day = (a) => new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
const diff = Math.round((day(now) - day(d)) / 86400000);
if (diff === 0) return "today";
if (diff === 1) return "yesterday";
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
function Row({ e, open, onToggle }) {
const kindColor = e.error ? C.danger : e.kind === "prompt" ? C.ok : C.t3;
return (
<div style={{ borderTop: `1px solid ${C.borderInner}` }}>
<div onClick={onToggle} style={{ display: "flex", alignItems: "baseline", gap: 10, padding: "6px 0", cursor: e.preview ? "pointer" : "default", fontSize: 12.5, flexWrap: "wrap" }}>
<span style={{ fontFamily: mono, fontSize: 11, color: C.t3, width: 84, flexShrink: 0, whiteSpace: "nowrap" }}>{fmtTime(e.ts)}</span>
<span style={{ fontFamily: mono, fontSize: 10.5, color: kindColor, width: 44, flexShrink: 0 }}>{e.error ? "error" : e.kind}</span>
<span style={{ fontFamily: mono, fontWeight: 600, color: e.error ? C.danger : C.ink, flexShrink: 0 }}>{e.name}</span>
<span style={{ color: C.tMuted, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.detail}</span>
<span style={{ fontFamily: mono, fontSize: 11, color: C.t3, flexShrink: 0 }}>{e.tokens_out ? `~${e.tokens_out} tk` : ""}</span>
</div>
{open && e.preview && (
<pre className="gc-scroll" style={{ margin: "0 0 10px 70px", padding: "10px 12px", background: C.subtle, border: `1px solid ${C.borderInner}`, borderRadius: 6, fontFamily: mono, fontSize: 11.5, lineHeight: 1.7, whiteSpace: "pre-wrap", overflow: "auto", maxHeight: 300 }}>
{e.preview}{e.preview.length >= 400 ? "\n┅ first 400 chars, the agent received the rest too" : ""}
</pre>
)}
</div>
);
}
export default function Activity() {
const [events, setEvents] = useState(null); // newest first
const [err, setErr] = useState(null);
const [open, setOpen] = useState(null); // event id expanded
const timer = useRef(null);
const load = () => getJSON("/api/events?limit=300")
.then((d) => { setEvents(d.events.slice().reverse()); setErr(null); })
.catch((e) => setErr(e.message));
useEffect(() => {
load();
timer.current = setInterval(() => { if (!document.hidden) load(); }, 3000);
const onFocus = () => { if (!document.hidden) load(); };
window.addEventListener("focus", onFocus);
return () => { clearInterval(timer.current); window.removeEventListener("focus", onFocus); };
}, []);
if (err) return <p style={{ fontFamily: mono, fontSize: 12, color: C.danger }}>{err}</p>;
if (!events) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3 }}>loading</p>;
if (events.length === 0) {
return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3 }}>no activity yet. Events appear here as harnesses connect and call tools. The feed empties on restart.</p>;
}
return (
<div>
<div style={{ ...label, marginBottom: 10 }}>activity · newest first · empties on restart</div>
{events.map((e) => (
<React.Fragment key={e.id}>
{e.kind === "connect" && (
<div style={{ fontFamily: mono, fontSize: 10.5, color: C.t3, padding: "14px 0 4px", letterSpacing: ".06em" }}>
session · {e.name} {e.detail} · {dayLabel(e.ts)} {fmtTime(e.ts)}
</div>
)}
{e.kind !== "connect" && (
<Row e={e} open={open === e.id} onToggle={() => setOpen(open === e.id ? null : e.id)} />
)}
</React.Fragment>
))}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import React, { useEffect, useState } from "react";
import { getJSON } from "./lib.js";
import { C, mono } from "./ui.jsx";
import Overview from "./Overview.jsx";
import Files from "./Files.jsx";
import Activity from "./Activity.jsx";
// Read-only local dashboard for one gcontext project: a plain sidebar and
// three views. Every view fetches fresh from /api/*; refetch on tab focus.
const SECTIONS = ["overview", "files", "activity"];
const savedSection = () => {
const s = localStorage.getItem("gc.section");
return SECTIONS.includes(s) ? s : "overview";
};
function Sidebar({ section, setSection, project, sessions }) {
return (
<nav style={{ width: 190, flexShrink: 0, borderRight: `1px solid ${C.border}`, padding: "28px 20px", display: "flex", flexDirection: "column", gap: 4, position: "sticky", top: 0, height: "100vh", boxSizing: "border-box" }}>
<div style={{ fontFamily: mono, fontSize: 14, fontWeight: 600, marginBottom: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={project?.project_dir}>
{project?.name || "gcontext"}
</div>
<div style={{ fontFamily: mono, fontSize: 11, color: sessions.length ? C.ok : C.t3, marginBottom: 20 }}>
{sessions.length ? `${sessions.length} connected` : "○ not connected"}
</div>
{SECTIONS.map((s) => (
<button key={s} onClick={() => setSection(s)}
style={{ all: "unset", cursor: "pointer", fontFamily: mono, fontSize: 12.5, padding: "3px 0", color: section === s ? C.ink : C.t3, fontWeight: section === s ? 600 : 400 }}>
{section === s ? " " : " "}{s}
</button>
))}
<div style={{ flex: 1 }} />
{project && <div style={{ fontFamily: mono, fontSize: 10.5, color: C.t3 }}>gcontext {project.version}</div>}
</nav>
);
}
export default function App() {
const [section, setSection] = useState(savedSection);
const [project, setProject] = useState(null);
const [sessions, setSessions] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => { localStorage.setItem("gc.section", section); }, [section]);
const refresh = () => {
getJSON("/api/project").then((p) => { setProject(p); setErr(null); }).catch((e) => setErr(e.message));
getJSON("/api/sessions").then((d) => setSessions(d.sessions)).catch(() => {});
};
useEffect(() => {
refresh();
const onFocus = () => { if (!document.hidden) refresh(); };
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onFocus);
return () => { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onFocus); };
}, []);
return (
<div style={{ minHeight: "100vh", background: C.bg, color: C.ink, display: "flex" }}>
<Sidebar section={section} setSection={setSection} project={project} sessions={sessions} />
<main style={{ flex: 1, minWidth: 0 }}>
<div style={{ maxWidth: 860, padding: "28px 32px 80px" }}>
{err && (
<p style={{ fontFamily: mono, fontSize: 12.5, color: C.danger, marginBottom: 20 }}>
cannot reach the server: {err}. Is `gcontext up` running?
</p>
)}
{section === "overview" && <Overview project={project} sessions={sessions} />}
{section === "files" && <Files />}
{section === "activity" && <Activity />}
</div>
</main>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { getJSON } from "./lib.js";
import { C, mono, label } from "./ui.jsx";
// Files = read-only browser over the project folder. Left: the tree from
// /api/tree (secrets.env and machine folders are excluded server-side).
// Right: the selected file, markdown rendered, everything else plain text.
function buildTree(entries) {
const roots = [];
const byPath = {};
for (const e of entries) {
const node = { ...e, children: [] };
byPath[e.path] = node;
const slash = e.path.lastIndexOf("/");
if (slash === -1) roots.push(node);
else byPath[e.path.slice(0, slash)]?.children.push(node);
}
const sortNodes = (nodes) => {
nodes.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name));
nodes.forEach((n) => sortNodes(n.children));
};
sortNodes(roots);
return roots;
}
function TreeRow({ node, depth, selected, open, onToggle, onSelect }) {
const isSel = selected === node.path;
return (
<>
<button
onClick={() => (node.dir ? onToggle(node.path) : onSelect(node.path))}
title={node.path}
style={{ all: "unset", cursor: "pointer", display: "block", width: "100%", boxSizing: "border-box", padding: "2px 0", paddingLeft: depth * 14, fontFamily: mono, fontSize: 12, lineHeight: 1.7, color: node.dir ? C.ink : isSel ? C.accent : C.t2, fontWeight: node.dir || isSel ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{node.dir ? (open.has(node.path) ? "▾ " : "▸ ") : " "}{node.name}{node.dir ? "/" : ""}
</button>
{node.dir && open.has(node.path) && node.children.map((c) => (
<TreeRow key={c.path} node={c} depth={depth + 1} selected={selected} open={open} onToggle={onToggle} onSelect={onSelect} />
))}
</>
);
}
function Viewer({ path }) {
const [file, setFile] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
if (!path) return;
setFile(null); setErr(null);
getJSON(`/api/file?path=${encodeURIComponent(path)}`).then(setFile).catch((e) => setErr(e.message));
}, [path]);
if (!path) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, margin: 0 }}>pick a file on the left</p>;
if (err) return <p style={{ fontFamily: mono, fontSize: 12, color: C.danger, margin: 0 }}>{path}: {err}</p>;
if (!file) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, margin: 0 }}>loading</p>;
return (
<div>
<div style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, marginBottom: 12, borderBottom: `1px solid ${C.borderInner}`, paddingBottom: 8 }}>
{file.path} · {file.size} B
</div>
{path.endsWith(".md") ? (
<div className="gc-md">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{file.content}</ReactMarkdown>
</div>
) : (
<pre className="gc-scroll" style={{ margin: 0, fontFamily: mono, fontSize: 12, lineHeight: 1.7, whiteSpace: "pre-wrap", overflow: "auto" }}>{file.content}</pre>
)}
</div>
);
}
export default function Files() {
const [entries, setEntries] = useState(null);
const [err, setErr] = useState(null);
const [selected, setSelected] = useState(null);
const [open, setOpen] = useState(() => new Set(["connections", "modules"]));
useEffect(() => { getJSON("/api/tree").then((d) => setEntries(d.tree)).catch((e) => setErr(e.message)); }, []);
const roots = useMemo(() => buildTree(entries || []), [entries]);
const toggle = (path) => setOpen((prev) => {
const next = new Set(prev);
next.has(path) ? next.delete(path) : next.add(path);
return next;
});
if (err) return <p style={{ fontFamily: mono, fontSize: 12, color: C.danger }}>{err}</p>;
if (!entries) return <p style={{ fontFamily: mono, fontSize: 11.5, color: C.t3 }}>loading</p>;
return (
<div style={{ display: "flex", gap: 32, alignItems: "flex-start", flexWrap: "wrap" }}>
<div className="gc-scroll" style={{ flex: "0 1 220px", minWidth: 180, maxHeight: "75vh", overflowY: "auto" }}>
<div style={{ ...label, marginBottom: 8 }}>project</div>
{roots.map((n) => (
<TreeRow key={n.path} node={n} depth={0} selected={selected} open={open} onToggle={toggle} onSelect={setSelected} />
))}
</div>
<div style={{ flex: "1 1 420px", minWidth: 300 }}>
<Viewer path={selected} />
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import React, { useEffect, useState } from "react";
import { getJSON, copyText, relSeen } from "./lib.js";
import { C, mono, label } from "./ui.jsx";
// Overview = the whole project on one page: sessions, how to connect,
// connections, modules, commands, and the context ledger. Plain lists.
function CopyLink({ text }) {
const [done, setDone] = useState(false);
return (
<button
onClick={() => { copyText(text); setDone(true); setTimeout(() => setDone(false), 1200); }}
style={{ all: "unset", cursor: "pointer", fontFamily: mono, fontSize: 11, color: done ? C.ok : C.accent, flexShrink: 0 }}>
{done ? "copied" : "copy"}
</button>
);
}
function Section({ title, children }) {
return (
<section style={{ marginBottom: 30 }}>
<div style={{ ...label, marginBottom: 10 }}>{title}</div>
{children}
</section>
);
}
const row = { display: "flex", alignItems: "baseline", gap: 10, padding: "6px 0", borderTop: `1px solid ${C.borderInner}`, fontSize: 13, flexWrap: "wrap" };
const dim = { fontFamily: mono, fontSize: 11.5, color: C.t3 };
export default function Overview({ project, sessions }) {
const [conns, setConns] = useState([]);
const [mods, setMods] = useState([]);
const [cmds, setCmds] = useState([]);
const [ledger, setLedger] = useState([]);
useEffect(() => {
getJSON("/api/connections").then(setConns).catch(() => {});
getJSON("/api/modules").then(setMods).catch(() => {});
getJSON("/api/commands").then(setCmds).catch(() => {});
getJSON("/api/ledger").then((d) => setLedger(d.ledger)).catch(() => {});
}, []);
if (!project) return <p style={{ ...dim }}>loading</p>;
const url = `${location.origin}/mcp`;
const connectCmd = `claude mcp add --transport http ${project.name} ${url}`;
const archived = Object.entries(project.archived || {}).map(([cat, items]) => `${items.length} ${cat}`).join(", ");
return (
<div>
<p style={{ margin: "0 0 4px", fontSize: 13.5, color: C.tMuted, maxWidth: 620, lineHeight: 1.6 }}>
{project.description || "No description in gcontext.yaml."}
</p>
<p style={{ ...dim, margin: "0 0 30px" }}>{project.project_dir}</p>
<Section title="sessions">
{sessions.length === 0 && <p style={{ ...dim, margin: 0 }}>none. Attach a harness with the command below</p>}
{sessions.map((s, i) => (
<div key={s.id || i} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
<span style={{ fontFamily: mono, fontWeight: 600 }}>{s.client}</span>
<span style={dim}>{s.version}</span>
<span style={{ flex: 1 }} />
<span style={dim}>last activity {relSeen(s.last_seen)}</span>
</div>
))}
<div style={{ display: "flex", alignItems: "baseline", gap: 10, marginTop: 12 }}>
<code style={{ fontFamily: mono, fontSize: 11.5, color: C.t2, overflowX: "auto", whiteSpace: "nowrap" }}>{connectCmd}</code>
<CopyLink text={connectCmd} />
</div>
<p style={{ ...dim, margin: "6px 0 0" }}>any MCP client: {url}</p>
</Section>
<Section title={`connections · ${conns.length}`}>
{conns.length === 0 && <p style={{ ...dim, margin: 0 }}>none. Add connections/&lt;service&gt;/connection.yaml</p>}
{conns.map((c, i) => (
<div key={c.name} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
<span style={{ fontFamily: mono, fontWeight: 600 }}>{c.name}</span>
<span style={{ fontFamily: mono, fontSize: 11.5, color: c.ready ? C.ok : C.danger }}>
{c.ready ? "ready" : "missing " + c.secrets.filter((s) => !s.filled).map((s) => s.name).join(", ")}
</span>
<span style={{ fontSize: 12.5, color: C.tMuted, flex: 1 }}>{c.description}</span>
</div>
))}
</Section>
<Section title={`modules · ${mods.length}`}>
{mods.length === 0 && <p style={{ ...dim, margin: 0 }}>none</p>}
{mods.map((m, i) => (
<div key={m.name} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
<span style={{ fontFamily: mono, fontWeight: 600 }}>{m.name}</span>
<span style={dim}>v{m.version}{m.tags?.length ? " · " + m.tags.join(", ") : ""}</span>
<span style={{ fontSize: 12.5, color: C.tMuted, flex: 1 }}>{m.description}</span>
</div>
))}
</Section>
<Section title={`commands · ${cmds.length}`}>
{cmds.length === 0 && <p style={{ ...dim, margin: 0 }}>none. Drop .md or .py files into a commands/ folder</p>}
{cmds.map((c, i) => (
<div key={c.path} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
<span style={{ fontFamily: mono, fontWeight: 600 }}>{c.name}</span>
{c.error
? <span style={{ fontSize: 12, color: C.danger }}>malformed: {c.error}</span>
: <span style={{ fontSize: 12.5, color: C.tMuted, flex: 1 }}>{c.description}</span>}
{!c.error && <CopyLink text={`/mcp__gcontext__${c.name}`} />}
</div>
))}
</Section>
<Section title="context ledger">
{ledger.map((p, i) => (
<div key={p.id} style={{ ...row, borderTop: i ? row.borderTop : "none" }}>
<span style={{ fontFamily: mono, fontSize: 11.5, color: C.t3, width: 22, flexShrink: 0 }}>{p.id}</span>
<span style={{ fontFamily: mono, fontSize: 12.5, width: 200, flexShrink: 0 }}>{p.label}</span>
<span style={{ fontFamily: mono, fontSize: 11.5, flexShrink: 0, color: p.status === "loaded" ? C.ok : p.status === "uncontrolled" ? C.amber : C.t3 }}>{p.status}</span>
<span style={{ fontSize: 12, color: C.tMuted, flex: 1, minWidth: 160 }}>{p.detail}</span>
</div>
))}
</Section>
<p style={{ ...dim, margin: 0 }}>
{project.has_instructions ? `instructions.md · ${project.instructions_lines} lines` : "no instructions.md"}
{archived ? ` · archive: ${archived}` : ""}
{` · gcontext ${project.version}`}
</p>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
color: #1C1B19;
}
::selection { background: #1C1B19; color: #fff; }
textarea, input, button { font-family: inherit; }
:focus { outline: none; }
:focus-visible { outline: 2px solid #c2603a; outline-offset: 2px; }
.gc-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
.gc-scroll::-webkit-scrollbar-thumb {
background: #DAD6CF; border-radius: 3px;
border: 3px solid transparent; background-clip: content-box;
}
.gc-scroll::-webkit-scrollbar-track { background: transparent; }
/* rendered markdown (file read view) */
.gc-md { font-size: 13px; line-height: 1.7; color: #2a2724; }
.gc-md > :first-child { margin-top: 0; }
.gc-md > :last-child { margin-bottom: 0; }
.gc-md h1, .gc-md h2, .gc-md h3, .gc-md h4 { line-height: 1.3; font-weight: 600; margin: 1.4em 0 .5em; }
.gc-md h1 { font-size: 1.5em; } .gc-md h3 { font-size: 1.12em; } .gc-md h4 { font-size: 1em; }
.gc-md h1 { padding-bottom: .28em; border-bottom: 1px solid #ECE8E1; }
/* h2 = mono overline with a trailing hairline */
.gc-md h2 { display: flex; align-items: center; gap: 10px; margin: 22px 0 11px; font-family: 'IBM Plex Mono', monospace; font-size: 10.5px; font-weight: 600; letter-spacing: .13em; text-transform: uppercase; color: rgba(0,0,0,.58); }
.gc-md h2::after { content: ""; flex: 1; height: 1px; background: #ddd7cb; }
.gc-md > h2:first-child { margin-top: 4px; }
.gc-md p, .gc-md ul, .gc-md ol, .gc-md blockquote, .gc-md table { margin: 0 0 .85em; }
.gc-md ul, .gc-md ol { padding-left: 1.5em; }
.gc-md li { margin: .2em 0; }
.gc-md a { color: #C2603A; text-decoration: none; }
.gc-md a:hover { text-decoration: underline; }
.gc-md code { font-family: 'IBM Plex Mono', monospace; font-size: 11px; background: #F4F1EB; border: 1px solid #ece8e1; padding: 1px 6px; border-radius: 5px; color: #1f1d1a; }
.gc-md pre { background: #F4F1EB; padding: 13px 15px; border-radius: 8px; overflow: auto; margin: 0 0 .85em; }
.gc-md pre code { background: none; border: none; padding: 0; font-size: 11.5px; line-height: 1.7; color: inherit; }
.gc-md blockquote { border-left: 3px solid #c9c4b8; padding-left: 1em; color: #4a4640; }
.gc-md .gc-callout p:last-child { margin-bottom: 0; }
.gc-md table { border-collapse: collapse; display: block; overflow: auto; }
.gc-md th, .gc-md td { border: 1px solid #ECE8E1; padding: 6px 11px; text-align: left; }
.gc-md th { background: #F7F5F1; font-weight: 600; }
.gc-md img { max-width: 100%; }
.gc-md hr { border: none; border-top: 1px solid #ECE8E1; margin: 1.4em 0; }
+30
View File
@@ -0,0 +1,30 @@
// The whole data seam: every view reads the local server's /api/* routes.
// The dashboard is read-only; the agent (via MCP) is what changes the project.
export async function getJSON(path) {
const r = await fetch(path);
// Non-JSON bodies (proxy 502 etc.) must not surface as parse errors.
const d = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
if (!r.ok || (d && d.error)) throw new Error((d && d.error) || `${r.status}`);
return d;
}
export function copyText(text) {
if (navigator.clipboard) return void navigator.clipboard.writeText(text);
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
// "3h ago" / "2d ago": the one relative-time format for last-seen surfaces.
export const relSeen = (iso) => {
const d = iso ? (Date.now() - new Date(iso).getTime()) / 86400000 : Infinity;
if (!isFinite(d)) return "never";
if (d < 1) { const h = Math.floor(d * 24); return h < 1 ? "just now" : `${h}h ago`; }
return `${Math.max(1, Math.round(d))}d ago`;
};
+6
View File
@@ -0,0 +1,6 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(<App />);
+40
View File
@@ -0,0 +1,40 @@
// Minimal design tokens: warm paper background, ink text, IBM Plex.
// Everything else is plain elements styled inline where they are used.
import React, { useState } from "react";
import { copyText } from "./lib.js";
export const C = {
bg: "#efece8",
panel: "#fff",
subtle: "#faf8f3",
ink: "#1f1d1a",
t2: "#4A4842",
tMuted: "rgba(0,0,0,.55)",
t3: "rgba(0,0,0,.45)",
border: "#e6e1d6",
borderInner: "#eee7da",
accent: "#c2603a",
ok: "#3d6b4a",
danger: "#a8492a",
amber: "#8a6d2e",
};
export const mono = "'IBM Plex Mono', ui-monospace, Menlo, monospace";
// Uppercase section label.
export const label = { fontFamily: mono, fontSize: 11, fontWeight: 600, letterSpacing: ".09em", textTransform: "uppercase", color: C.t3 };
// The one copy affordance: a small text link that flips to "copied".
// Used for connect commands, slash commands, and file/folder references.
export function CopyLink({ text, children, style }) {
const [done, setDone] = useState(false);
return (
<button
title={`copy ${text}`}
onClick={(e) => { e.stopPropagation(); copyText(text); setDone(true); setTimeout(() => setDone(false), 1200); }}
style={{ all: "unset", cursor: "pointer", fontFamily: mono, fontSize: 11, color: done ? C.ok : C.accent, flexShrink: 0, ...style }}>
{done ? "copied" : children || "copy"}
</button>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Dev server proxies API calls to a running `gcontext up` server.
// Point elsewhere with VITE_API=http://127.0.0.1:4299 npm run dev
const API = process.env.VITE_API || "http://127.0.0.1:4242";
export default defineConfig({
plugins: [react()],
server: {
port: 5179,
strictPort: true,
proxy: {
"/api": { target: API, changeOrigin: true },
"/status": { target: API, changeOrigin: true },
},
},
});