<title>cctop</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<style>__CCTOP_CSS__
.totals { display: flex; gap: 18px; flex-wrap: wrap; align-items: baseline; }
.totals b { font-family: var(--mono); font-weight: 600; }
.totals .k { color: var(--faint); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.controls { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
input[type=search] {
flex: 1 1 200px; min-width: 0; padding: 7px 11px; font: inherit; font-size: 14px;
background: var(--panel); color: var(--ink);
border: 1px solid var(--line); border-radius: 8px;
}
input[type=search]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button {
padding: 7px 11px; font: inherit; font-size: 13px; cursor: pointer;
background: var(--panel); color: var(--dim);
border: 1px solid var(--line); border-radius: 8px;
}
button[aria-pressed=true] { color: var(--accent); border-color: var(--accent); }
select {
padding: 7px 9px; font: inherit; font-size: 13px; cursor: pointer;
background: var(--panel); color: var(--dim);
border: 1px solid var(--line); border-radius: 8px;
}
.attention { border-color: var(--amber); margin-bottom: 16px; }
.attention h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
color: var(--amber); padding: 11px 14px 0; }
.rows { display: block; }
.row {
display: grid; gap: 2px 10px; padding: 11px 14px; border-top: 1px solid var(--line);
grid-template-columns: 8px minmax(0, 1fr) auto;
text-decoration: none; color: inherit; cursor: pointer;
}
.row:first-of-type { border-top: 0; }
.row:hover, .row:focus-visible { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.row .dot { margin-top: 7px; }
.row .name { min-width: 0; font-weight: 500; }
.row .name .title { color: var(--dim); font-weight: 400; }
.row .meta { grid-column: 2; display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--faint); }
.row .figures { grid-row: 1 / span 2; grid-column: 3; text-align: right; font-family: var(--mono);
font-variant-numeric: tabular-nums; font-size: 13px; white-space: nowrap; }
.row .figures .sub { font-size: 11px; color: var(--faint); }
.trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctx { display: inline-block; width: 46px; height: 5px; border-radius: 3px;
background: var(--line); overflow: hidden; vertical-align: middle; }
.ctx i { display: block; height: 100%; background: var(--dim); }
.wanting { border-top: 1px solid var(--line); }
.wanting:first-child { border-top: 0; }
.wanting .row { border-top: 0; }
form.say { display: flex; gap: 8px; padding: 0 14px 11px 32px; }
form.say input {
flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 6px 9px;
border-radius: 7px; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
}
form.say input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
form.say button {
font: inherit; font-size: 13px; padding: 0 13px; border-radius: 7px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: var(--panel);
}
form.say button:disabled { opacity: .5; cursor: default; }
form.say .said { font-size: 12px; align-self: center; color: var(--faint); }
form.say .said.bad { color: var(--red); }
.ctx i.hot { background: var(--amber); }
.ctx i.full { background: var(--red); }
footer { margin-top: 20px; font-size: 12px; color: var(--faint);
display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.live { display: inline-flex; align-items: center; gap: 6px; }
</style>
<div class="wrap">
<header class="top">
<h1>cctop<span class="v mono">__CCTOP_VERSION__</span></h1>
<div class="spacer"></div>
<div class="totals" id="totals"></div>
</header>
<div id="banners"></div>
<div class="controls">
<input type="search" id="filter" placeholder="Filter on project, model, branch, title…" autocomplete="off" spellcheck="false">
<button id="running" aria-pressed="false" title="Show only sessions with a live process">Running</button>
<button id="bell" aria-pressed="false" title="Notify when a session starts waiting on you">Notify</button>
<select id="sort" title="Order the table">
<option value="recent">Recent</option>
<option value="cost">Cost</option>
<option value="tokens">Tokens</option>
<option value="context">Context</option>
</select>
</div>
<section class="card attention" id="attention" hidden>
<h2 id="attention-heading"></h2>
<div class="rows" id="attention-rows"></div>
</section>
<section class="card">
<div class="rows" id="rows"></div>
<div class="empty" id="empty">Waiting for the first refresh…</div>
</section>
<footer>
<span class="live"><span class="dot idle" id="link"></span><span id="link-text">connecting…</span></span>
<span class="spacer"></span>
<span id="counts"></span>
</footer>
</div>
<script>
"use strict";
const TOKEN = "__CCTOP_TOKEN__";
const QUERY = TOKEN ? "?t=" + encodeURIComponent(TOKEN) : "";
const CAN_ACT = "__CCTOP_ACTIONS__";
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined && text !== null) node.textContent = String(text);
return node;
};
const money = (n) => {
if (n === null || n === undefined) return "—";
const v = Number(n);
if (!isFinite(v)) return "—";
if (v === 0) return "$0";
if (v < 0.01) return "<$0.01";
return "$" + (v < 10 ? v.toFixed(2) : Math.round(v).toLocaleString());
};
const tokens = (n) => {
const v = Number(n) || 0;
if (v >= 1e9) return (v / 1e9).toFixed(1) + "G";
if (v >= 1e6) return (v / 1e6).toFixed(1) + "M";
if (v >= 1e3) return (v / 1e3).toFixed(1) + "k";
return String(v);
};
const ago = (iso) => {
const then = Date.parse(iso);
if (!isFinite(then)) return "";
const secs = Math.max(0, (Date.now() - then) / 1000);
if (secs < 60) return Math.floor(secs) + "s";
if (secs < 3600) return Math.floor(secs / 60) + "m";
if (secs < 86400) return Math.floor(secs / 3600) + "h";
return Math.floor(secs / 86400) + "d";
};
const rowCost = (s) => {
if (s.cost.included) return "incl";
if (!s.cost.available) return "—";
if (s.cost.total === null || s.cost.total === undefined) return "—";
return money(Number(s.cost.total));
};
const HOME = "__CCTOP_HOME__";
const shortPath = (path) => {
const full = String(path);
if (HOME && full.startsWith(HOME + "/")) return "~" + full.slice(HOME.length);
if (HOME && full === HOME) return "~";
const parts = full.split("/").filter(Boolean);
return parts.length <= 2 ? full : "…/" + parts.slice(-2).join("/");
};
const shortModel = (model) => {
const parts = String(model).split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : model;
};
let sessions = [];
let filter = "";
let runningOnly = false;
let sortBy = "recent";
const ORDER = {
recent: (a, b) => String(b.last_active).localeCompare(String(a.last_active)),
cost: (a, b) => (Number(b.cost.total) || 0) - (Number(a.cost.total) || 0),
tokens: (a, b) => (Number(b.tokens.total) || 0) - (Number(a.tokens.total) || 0),
context: (a, b) => share(b) - share(a),
};
const share = (s) => (s.context && s.context.max ? s.context.used / s.context.max : -1);
let previousState = new Map();
let firstRender = true;
const matches = (s, needle) => {
if (!needle) return true;
const hay = [
s.project, s.title, s.model, s.harness, s.branch, s.provider,
s.session_id, s.user, s.state,
].filter(Boolean).join(" ").toLowerCase();
return needle.split(/\s+/).filter(Boolean).every((word) => hay.includes(word));
};
function contextBar(s) {
const box = el("span", "ctx");
const ctx = s.context;
if (!ctx || !ctx.max) return null;
const pct = Math.min(100, (ctx.used / ctx.max) * 100);
const fill = el("i");
fill.style.width = pct.toFixed(1) + "%";
if (pct >= 90) fill.className = "full";
else if (pct >= 70) fill.className = "hot";
box.appendChild(fill);
box.title = Math.round(pct) + "% of the context window";
return box;
}
function rowFor(s) {
const row = el("a", "row");
row.href = "/session/" + encodeURIComponent(s.session_id) + QUERY;
row.setAttribute("role", "listitem");
row.appendChild(el("span", "dot " + (s.running || s.state === "error" ? s.state : "idle")));
const name = el("div", "name trunc");
name.appendChild(el("span", null, s.project ? shortPath(s.project) : s.session_id.slice(0, 8)));
if (s.title) {
name.appendChild(el("span", "title", " · " + s.title));
}
if (s.project) name.title = s.project;
row.appendChild(name);
const meta = el("div", "meta");
const tag = (text, cls) => { if (text) meta.appendChild(el("span", cls || null, text)); };
tag(s.model ? shortModel(s.model) : s.provider);
if (s.branch) tag(s.branch);
tag(ago(s.last_active) + " ago");
if (s.running && s.state === "waiting") tag("waiting on you", "pill warn");
if (s.running && s.state === "asking") tag("needs permission", "pill bad");
if (s.state === "error") tag(s.running ? "api error" : "ended on an api error", "pill bad");
if (s.activity.tool_errors > 0 && s.activity.tool_count > 0) {
const rate = s.activity.tool_errors / s.activity.tool_count;
if (rate >= 0.25) tag(Math.round(rate * 100) + "% tool errors", "pill bad");
}
if (s.conflict) tag(s.conflict.level === "file" ? "same file as another agent" : "same repo as another agent", "pill warn");
if (s.user) tag(s.user);
if (s.profile && s.profile !== "default") tag(s.profile);
const bar = contextBar(s);
if (bar) meta.appendChild(bar);
row.appendChild(meta);
const figures = el("div", "figures");
figures.appendChild(el("div", null, rowCost(s)));
figures.appendChild(el("div", "sub", tokens(s.tokens.total) + " tok"));
row.appendChild(figures);
return row;
}
async function problem(response) {
const kind = (response.headers.get("content-type") || "").split(";")[0].trim();
const said = kind === "text/plain" ? (await response.text()).trim() : "";
if (said) return said.length > 400 ? said.slice(0, 400) + "…" : said;
if (response.status >= 502 && response.status <= 504) return "cctop is not answering";
if (response.status === 401 || response.status === 403) return "this link is no longer authorised";
return "the server answered " + response.status;
}
async function asJson(response) {
const text = await response.text();
try {
return JSON.parse(text);
} catch (e) {
throw new Error("whatever answered this page, it was not cctop");
}
}
async function ask(url, init) {
let response;
try {
response = await fetch(url, init);
} catch (e) {
throw new Error("cctop is unreachable");
}
if (!response.ok) throw new Error(await problem(response));
return response;
}
function answerBox(s) {
if (!CAN_ACT || !s.running) return null;
const form = el("form", "say");
const input = el("input");
input.type = "text";
input.maxLength = 4000;
input.autocomplete = "off";
input.placeholder = "Answer this session…";
const send = el("button", null, "Send");
send.type = "submit";
const said = el("span", "said", "");
form.appendChild(input);
form.appendChild(send);
form.appendChild(said);
input.addEventListener("paste", (event) => {
const items = Array.from(event.clipboardData?.items || []);
const picture = items.find((i) => i.type && i.type.startsWith("image/"));
if (!picture) return;
const file = picture.getAsFile();
if (!file) return;
event.preventDefault();
said.className = "said";
said.textContent = "filing the image…";
const reader = new FileReader();
reader.onerror = () => {
said.className = "said bad";
said.textContent = "could not read that image";
};
reader.onload = async () => {
try {
const response = await ask(
"/api/act/image/" + encodeURIComponent(s.session_id) + QUERY,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: String(reader.result) }),
},
);
const filed = await response.json();
const room = input.value && !input.value.endsWith(" ") ? " " : "";
input.value = input.value + room + filed.path + " ";
input.focus();
said.textContent = "image filed on that machine";
} catch (e) {
said.className = "said bad";
said.textContent = String(e.message || e);
}
};
reader.readAsDataURL(file);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
send.disabled = true;
said.className = "said";
said.textContent = "…";
try {
await ask(
"/api/act/send/" + encodeURIComponent(s.session_id) + QUERY,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
},
);
input.value = "";
said.textContent = "sent";
} catch (e) {
said.className = "said bad";
said.textContent = String(e.message || e);
} finally {
send.disabled = false;
}
});
return form;
}
function wantingNode(s) {
const box = el("div", "wanting");
box.appendChild(rowFor(s));
const say = answerBox(s);
if (say) box.appendChild(say);
return box;
}
function render() {
const shown = sessions
.filter((s) => matches(s, filter))
.filter((s) => !runningOnly || s.running)
.sort(ORDER[sortBy] || ORDER.recent);
const wanting = shown.filter(
(s) =>
s.running &&
(s.state === "waiting" || s.state === "asking" || s.state === "error"),
);
const rest = shown.filter((s) => !wanting.includes(s));
const attention = document.getElementById("attention");
const attentionRows = document.getElementById("attention-rows");
const typed = new Map();
let focused = null;
let caret = 0;
for (const form of attentionRows.querySelectorAll("form.say")) {
const input = form.querySelector("input");
if (!form.dataset.session) continue;
if (input.value) typed.set(form.dataset.session, input.value);
if (document.activeElement === input) {
focused = form.dataset.session;
caret = input.selectionStart;
}
}
attentionRows.replaceChildren(...wanting.map((s) => {
const node = wantingNode(s);
const form = node.querySelector("form.say");
if (!form) return node;
form.dataset.session = s.session_id;
const input = form.querySelector("input");
const carried = typed.get(s.session_id);
if (carried) input.value = carried;
if (focused === s.session_id) {
input.focus();
input.setSelectionRange(caret, caret);
}
return node;
}));
attention.hidden = wanting.length === 0;
document.getElementById("attention-heading").textContent =
wanting.length === 1 ? "1 session needs you" : wanting.length + " sessions need you";
document.getElementById("rows").replaceChildren(...rest.map(rowFor));
const empty = document.getElementById("empty");
empty.hidden = shown.length > 0;
if (shown.length === 0 && sessions.length > 0) empty.textContent = "Nothing matches that filter.";
const running = sessions.filter((s) => s.running).length;
document.getElementById("counts").textContent =
sessions.length + " sessions · " + running + " running" +
(shown.length !== sessions.length ? " · " + shown.length + " shown" : "");
const totals = document.getElementById("totals");
const today = sessions.reduce((sum, s) => sum + (s.cost.today || 0), 0);
const hour = sessions.reduce((sum, s) => sum + (s.cost.this_hour || 0), 0);
totals.replaceChildren();
for (const [label, value] of [["today", money(today)], ["this hour", money(hour)]]) {
const box = el("span");
box.appendChild(el("b", null, value));
box.appendChild(document.createTextNode(" "));
box.appendChild(el("span", "k", label));
totals.appendChild(box);
}
}
const bell = document.getElementById("bell");
let notifying = false;
bell.addEventListener("click", async () => {
if (notifying) {
notifying = false;
bell.setAttribute("aria-pressed", "false");
return;
}
if (!("Notification" in window)) {
bell.textContent = "No notifications";
bell.disabled = true;
return;
}
const granted = await Notification.requestPermission();
notifying = granted === "granted";
bell.setAttribute("aria-pressed", String(notifying));
if (!notifying) bell.title = "Your browser refused notification permission for this page";
});
function announce(next) {
if (!notifying || firstRender) return;
for (const s of next) {
const was = previousState.get(s.session_id);
if (was && was !== "asking" && s.state === "asking") {
new Notification("Needs permission", {
body: (s.project || s.session_id.slice(0, 8)) + (s.title ? " · " + s.title : ""),
tag: s.session_id,
});
}
if (was && was !== "waiting" && s.state === "waiting") {
new Notification("Waiting on you", {
body: (s.project || s.session_id.slice(0, 8)) + (s.title ? " · " + s.title : ""),
tag: s.session_id,
});
}
}
}
function apply(next) {
announce(next);
previousState = new Map(next.map((s) => [s.session_id, s.state]));
sessions = next;
firstRender = false;
render();
}
const link = document.getElementById("link");
const linkText = document.getElementById("link-text");
function setLink(state, text) {
link.className = "dot " + state;
linkText.textContent = text;
}
let source;
function connect() {
source = new EventSource("/api/events" + QUERY);
source.addEventListener("sessions", (event) => {
setLink("working", "live");
try { apply(JSON.parse(event.data)); } catch (e) { setLink("error", "bad payload"); }
});
source.addEventListener("error", () => {
if (source.readyState !== EventSource.CLOSED) {
setLink("waiting", "reconnecting…");
return;
}
setLink("error", "cctop is unreachable");
setTimeout(connect, 5000);
});
source.addEventListener("open", () => setLink("working", "live"));
}
ask("/api/hosts" + QUERY)
.then(asJson)
.then((failed) => {
const banners = document.getElementById("banners");
for (const [host, why] of failed) {
banners.appendChild(el("div", "banner", host + " could not be read: " + why));
}
})
.catch(() => {});
document.getElementById("filter").addEventListener("input", (e) => {
filter = e.target.value.trim().toLowerCase();
render();
});
document.getElementById("sort").addEventListener("change", (e) => {
sortBy = e.target.value;
render();
});
document.getElementById("running").addEventListener("click", (e) => {
runningOnly = !runningOnly;
e.currentTarget.setAttribute("aria-pressed", String(runningOnly));
render();
});
setInterval(() => { if (sessions.length) render(); }, 15000);
connect();
</script>