<title>cctop — session report</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<style>__CCTOP_CSS__
.back { font-size: 13px; color: var(--dim); text-decoration: none; }
.back:hover { color: var(--accent); }
.subject { margin-bottom: 18px; }
.subject h2 { font-size: 20px; margin-bottom: 4px; }
.subject .where { color: var(--dim); font-size: 13px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.tiles { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); margin-bottom: 22px; }
.tile { padding: 12px 14px; }
.tile .k { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); }
.tile .v { font-family: var(--mono); font-size: 21px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; }
.tile .n { font-size: 11px; color: var(--faint); margin-top: 2px; }
.tile.bad .v { color: var(--red); }
.tile.warn .v { color: var(--amber); }
section.block { margin-bottom: 26px; }
section.block > h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em;
color: var(--faint); margin-bottom: 9px; }
section.block > .note { font-size: 12px; color: var(--faint); margin: -4px 0 9px; max-width: 62ch; }
.pad { padding: 12px 14px; }
.failure { border-top: 1px solid var(--line); padding: 11px 14px; }
.failure:first-child { border-top: 0; }
.failure .head { display: flex; gap: 9px; align-items: baseline; flex-wrap: wrap; }
.failure .count { font-family: var(--mono); font-weight: 600; color: var(--red); }
.failure .tool { font-family: var(--mono); font-size: 12px; color: var(--dim); }
.failure pre { margin: 6px 0 0; font-family: var(--mono); font-size: 12px; color: var(--ink);
background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
.stack { display: flex; height: 22px; border-radius: 6px; overflow: hidden; background: var(--line); }
.stack span { display: block; height: 100%; min-width: 0; }
.legend { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 11px; font-size: 12px; }
.legend .item { display: flex; align-items: center; gap: 6px; }
.legend .swatch { width: 9px; height: 9px; border-radius: 2px; flex: 0 0 9px; }
.legend .amt { color: var(--faint); font-family: var(--mono); }
svg { display: block; max-width: 100%; }
.axis { fill: var(--faint); font-size: 10px; font-family: var(--mono); }
.gridline { stroke: var(--line); stroke-width: 1; }
.files { columns: 2 260px; column-gap: 22px; font-family: var(--mono); font-size: 12px; }
.files div { break-inside: avoid; color: var(--dim); padding: 1px 0; overflow-wrap: anywhere; }
.diff { border-top: 1px solid var(--line); }
.diff:first-child { border-top: 0; }
.diff > summary {
padding: 9px 14px; cursor: pointer; display: flex; gap: 10px;
align-items: baseline; flex-wrap: wrap; list-style: none;
}
.diff > summary::-webkit-details-marker { display: none; }
.diff > summary::before { content: "▸"; color: var(--faint); flex: 0 0 auto; }
.diff[open] > summary::before { content: "▾"; }
.diff > summary:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.diff .path { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
.diff .plus { color: var(--green); font-family: var(--mono); font-size: 12px; }
.diff .minus { color: var(--red); font-family: var(--mono); font-size: 12px; }
.diff pre {
margin: 0; padding: 8px 0 12px; font-family: var(--mono); font-size: 12px;
line-height: 1.45; overflow-x: auto;
}
.diff pre span { display: block; padding: 0 14px; white-space: pre; }
.diff pre .add { background: color-mix(in srgb, var(--green) 14%, transparent); }
.diff pre .del { background: color-mix(in srgb, var(--red) 14%, transparent); }
.diff pre .meta { color: var(--faint); }
.log { max-height: 60vh; overflow-y: auto; }
.log table { font-size: 12px; }
.log thead th { position: sticky; top: 0; background: var(--panel); z-index: 1; padding-top: 10px; }
.log td { padding: 4px 10px; }
.log tr.bad td { background: color-mix(in srgb, var(--red) 9%, transparent); }
.log .when { color: var(--faint); white-space: nowrap; font-family: var(--mono); }
nav.views { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 18px; }
nav.views button {
font: inherit; font-size: 13px; padding: 5px 12px; border-radius: 99px;
border: 1px solid var(--line); background: var(--panel); color: var(--dim);
cursor: pointer;
}
nav.views button:hover { color: var(--ink); }
nav.views button[aria-selected="true"] { color: var(--panel); background: var(--accent); border-color: var(--accent); }
nav.views button .n { font-family: var(--mono); font-size: 11px; opacity: .75; margin-left: 5px; }
.split { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); align-items: start; }
.split.with-term { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.termside { display: flex; flex-direction: column; min-width: 0;
position: sticky; top: 12px; }
.termside[hidden] { display: none; }
.termside .head { display: flex; align-items: center; gap: 10px; margin-bottom: 7px;
font-size: 12px; color: var(--faint); }
.termside .head button {
font: inherit; font-size: 12px; margin-left: auto; padding: 3px 10px; border-radius: 99px;
border: 1px solid var(--line); background: var(--panel); color: var(--dim); cursor: pointer;
}
.termside .head button:hover { color: var(--accent); border-color: var(--accent); }
.termcard { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.termcard .open {
font-size: 13px; padding: 6px 14px; border-radius: 7px; text-decoration: none;
border: 1px solid var(--accent); background: var(--accent); color: var(--panel);
}
.termcard .why { font-size: 12px; color: var(--faint); }
.termcard .why.bad { color: var(--amber); }
.termside .note { font-size: 12px; color: var(--amber); margin-top: 7px; }
nav.views button.toggle[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
@media (max-width: 900px) {
.split.with-term { grid-template-columns: minmax(0, 1fr); }
.termside { position: static; }
}
.acts { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
.acts button, .acts select {
font: inherit; font-size: 13px; padding: 5px 11px; border-radius: 7px;
border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer;
}
.acts button:hover:enabled { border-color: var(--accent); color: var(--accent); }
.acts button:disabled { color: var(--faint); cursor: default; }
.said { font-size: 13px; margin-top: 10px; }
.said.bad { color: var(--red); }
.said.ok { color: var(--green); }
form.say { display: flex; gap: 8px; padding: 10px; position: sticky; bottom: 10px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
box-shadow: var(--shadow); margin-top: 14px; }
form.say input {
flex: 1 1 auto; font: inherit; font-size: 14px; padding: 8px 10px; min-width: 0;
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 16px; border-radius: 7px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: var(--panel);
}
form.say button:disabled { opacity: .5; cursor: default; }
.turn { border-top: 1px solid var(--line); padding: 12px 14px; }
.turn:first-child { border-top: 0; }
.turn > .who {
display: flex; gap: 8px; align-items: baseline; font-size: 11px;
text-transform: uppercase; letter-spacing: .06em; color: var(--faint); margin-bottom: 5px;
}
.turn > .said-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
.turn.user { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.turn.user > .who { color: var(--accent); }
.turn.system > .said-text, .turn.reasoning > .said-text,
.turn.system > .md, .turn.reasoning > .md { color: var(--dim); font-size: 13px; }
.md { font-size: 14px; overflow-wrap: anywhere; }
.md > * { margin: 0 0 9px; }
.md > *:last-child { margin-bottom: 0; }
.md p { white-space: pre-wrap; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
font-size: 1em; font-weight: 600; margin-top: 14px; line-height: 1.35;
}
.md h1 { font-size: 1.15em; }
.md h2 { font-size: 1.07em; }
.md ul, .md ol { padding-left: 21px; }
.md li { margin: 2px 0; }
.md li > .md { margin: 2px 0; }
.md code {
font-family: var(--mono); font-size: .87em; padding: 0 4px; border-radius: 4px;
background: var(--bg); border: 1px solid var(--line);
}
.md pre {
background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
padding: 8px 10px; overflow-x: auto;
}
.md pre code { border: 0; background: none; padding: 0; white-space: pre; font-size: 12px; }
.md .scroll { overflow-x: auto; }
.md table { font-size: 13px; width: auto; max-width: 100%; }
.md th {
text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600;
color: var(--ink); padding: 0 10px 7px; white-space: normal;
}
.md td { white-space: normal; }
.md blockquote { border-left: 2px solid var(--line); padding-left: 11px; color: var(--dim); }
.md hr { border: 0; border-top: 1px solid var(--line); }
.md a { color: var(--accent); }
.turn.compaction { text-align: center; color: var(--faint); font-size: 12px; }
.turn .clip { font-size: 11px; color: var(--faint); margin-top: 4px; }
details.tool { margin-top: 7px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); }
details.tool > summary {
padding: 6px 10px; cursor: pointer; display: flex; gap: 8px; align-items: baseline;
flex-wrap: wrap; list-style: none; font-size: 12.5px;
}
details.tool > summary::-webkit-details-marker { display: none; }
details.tool > summary .name { font-family: var(--mono); font-weight: 600; }
details.tool > summary .arg { font-family: var(--mono); color: var(--dim); overflow-wrap: anywhere; }
details.tool.failed { border-color: var(--red); }
details.tool.failed > summary .name { color: var(--red); }
details.tool.running > summary .name { color: var(--amber); }
details.tool pre {
margin: 0; padding: 8px 10px; border-top: 1px solid var(--line); font-family: var(--mono);
font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dim);
}
details.tool pre.patch { white-space: pre; overflow-x: auto; padding: 8px 0; color: var(--ink); }
details.tool pre.patch span { display: block; padding: 0 10px; }
details.tool pre.patch .add { background: color-mix(in srgb, var(--green) 14%, transparent); }
details.tool pre.patch .del { background: color-mix(in srgb, var(--red) 14%, transparent); }
details.tool pre.patch .meta { color: var(--faint); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 5px 14px; font-size: 13px; }
.kv dt { color: var(--faint); }
.kv dd { margin: 0; overflow-wrap: anywhere; }
.rule { border-top: 1px solid var(--line); }
.rule:first-child { border-top: 0; }
.rule > summary {
padding: 9px 14px; cursor: pointer; display: flex; gap: 10px; align-items: baseline;
flex-wrap: wrap; list-style: none; font-size: 13px;
}
.rule > summary::-webkit-details-marker { display: none; }
.rule > summary .path { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
.rule[data-present="false"] > summary { color: var(--faint); cursor: default; }
.rule pre {
margin: 0; padding: 10px 14px; border-top: 1px solid var(--line); font-family: var(--mono);
font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 50vh; overflow-y: auto;
}
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font-size: 12px; padding: 2px 9px; border-radius: 99px; border: 1px solid var(--line); }
.chip .n { font-family: var(--mono); color: var(--faint); margin-left: 5px; }
</style>
<div class="wrap">
<header class="top">
<a class="back" href="/__CCTOP_BACK__">← all sessions</a>
<div class="spacer"></div>
<span class="faint mono" style="font-size:12px">cctop __CCTOP_VERSION__</span>
</header>
<main id="main"><div class="empty">Reading the transcript…</div></main>
</div>
<script>
"use strict";
const TOKEN = "__CCTOP_TOKEN__";
const QUERY = TOKEN ? "?t=" + encodeURIComponent(TOKEN) : "";
const ID = decodeURIComponent(location.pathname.replace(/^\/session\//, ""));
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 svg = (tag, attrs) => {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, String(v));
return node;
};
const money = (v) => {
const n = Number(v) || 0;
if (n === 0) return "$0";
if (n < 0.01) return "<$0.01";
return "$" + (n < 100 ? n.toFixed(2) : Math.round(n).toLocaleString());
};
const tokens = (v) => {
const n = Number(v) || 0;
if (n >= 1e9) return (n / 1e9).toFixed(2) + "G";
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(n);
};
const ago = (iso) => {
const then = Date.parse(iso);
if (!isFinite(then)) return "";
const s = Math.max(0, (Date.now() - then) / 1000);
if (s < 60) return Math.floor(s) + "s ago";
if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago";
return Math.floor(s / 86400) + "d ago";
};
const secs = (ms) => {
const n = Number(ms) || 0;
if (n >= 60000) return Math.round(n / 60000) + "m";
if (n >= 1000) return (n / 1000).toFixed(1) + "s";
return n + "ms";
};
const block = (heading, note) => {
const s = el("section", "block");
s.appendChild(el("h3", null, heading));
if (note) s.appendChild(el("p", "note", note));
return s;
};
const when = (r) => {
const spansDays =
new Date(r.started_at).toDateString() !== new Date(r.last_active).toDateString();
const start = clock(r.started_at, spansDays);
if (!start) return r.duration;
const tail = r.running ? "still going" : ago(r.last_active);
return start + " · " + r.duration + " · " + tail;
};
function subject(r) {
const box = el("div", "subject");
box.appendChild(el("h2", null, r.title || r.project || r.session_id));
const where = el("div", "where");
where.appendChild(el("span", "dot " + (r.running ? r.state : "idle")));
const tag = (t) => { if (t) where.appendChild(el("span", null, t)); };
tag(r.project);
tag(r.branch);
if (r.profile && r.profile !== "default") tag("profile: " + r.profile);
tag(r.model || r.provider);
tag(when(r));
if (r.plan !== "retail") tag("plan: " + r.plan);
where.appendChild(el("span", "faint mono", r.session_id));
box.appendChild(where);
return box;
}
function tiles(r) {
const grid = el("div", "tiles");
const add = (k, v, note, cls) => {
const t = el("div", "card tile" + (cls ? " " + cls : ""));
t.appendChild(el("div", "k", k));
t.appendChild(el("div", "v", v));
if (note) t.appendChild(el("div", "n", note));
grid.appendChild(t);
};
const cost = r.cost.included ? "incl" : (r.cost.available ? money(r.cost.total) : "—");
add("Cost", cost, r.cost.included ? "bundled by this plan" : null);
add("Tokens", tokens(r.tokens.total || (r.tokens.input + r.tokens.output)),
tokens(r.tokens.input) + " in · " + tokens(r.tokens.output) + " out");
add("Tool calls", (r.activity.tool_count || 0).toLocaleString());
if (r.activity.error_rate !== null && r.activity.error_rate !== undefined) {
const pct = Math.round(r.activity.error_rate * 100);
add("Tool errors", pct + "%", (r.activity.tool_errors || 0) + " failed",
pct >= 25 ? "bad" : (pct >= 10 ? "warn" : null));
}
if (r.context) {
const pct = Math.round(r.context.percent_to_compact);
add("Context", pct + "%", tokens(r.context.used) + " of " + tokens(r.context.max),
pct >= 90 ? "bad" : (pct >= 70 ? "warn" : null));
if (r.context.compactions > 0) {
add("Compactions", r.context.compactions, null, r.context.compactions >= 3 ? "warn" : null);
}
}
if (r.activity.lines_added || r.activity.lines_removed) {
add("Lines", "+" + r.activity.lines_added, "−" + r.activity.lines_removed);
}
return grid;
}
function failures(r) {
const found = r.activity.failures || [];
if (!found.length) return null;
const repeated = found.filter((f) => f.count > 1).length;
const s = block(
repeated ? "Repeated failures" : "Failed tool calls",
repeated
? "Grouped by tool and by argument. A count above one is the same call retried — the agent paid for every attempt."
: "Grouped by tool and by argument. Nothing repeated, so these are one-offs rather than a loop."
);
const card = el("div", "card");
for (const f of found) {
const item = el("div", "failure");
const head = el("div", "head");
if (f.count > 1) head.appendChild(el("span", "count", "×" + f.count));
head.appendChild(el("span", "tool", f.tool));
const when = f.samples && f.samples.length ? f.samples[0].ts : null;
if (when) head.appendChild(el("span", "faint", "first at " + when.replace("T", " ").replace(/\..*$/, "")));
item.appendChild(head);
item.appendChild(el("pre", null, f.detail));
card.appendChild(item);
}
s.appendChild(card);
return s;
}
const CONTEXT_PARTS = [
["startup", "Startup", "var(--dim)"],
["tool_output", "Tool output", "var(--accent)"],
["tool_input", "Tool input", "var(--amber)"],
["attachments", "Attachments", "var(--green)"],
["user_text", "You", "var(--red)"],
["assistant_text", "Assistant", "var(--faint)"],
];
function breakdown(r) {
const b = r.context && r.context.breakdown;
if (!b || !b.total) return null;
const s = block(
"What is in the context window",
"Startup and the window total are exact; the rest is estimated from transcript characters, so read them as proportions. " +
(b.unaccounted >= 0
? "Unaccounted is thinking — stored stripped — plus the harness's per-turn reminders and estimation error."
: "The estimate overshoots the measured window, which happens when the harness has dropped old tool results the transcript still holds.") +
(b.superseded ? " A compaction has since replaced this segment, so these describe the window before it." : "")
);
const card = el("div", "card pad");
const bar = el("div", "stack");
const legend = el("div", "legend");
const parts = CONTEXT_PARTS.map(([key, label, color]) => [label, b[key] || 0, color]);
if (b.unaccounted > 0) parts.push(["Unaccounted", b.unaccounted, "var(--line)"]);
const shown = parts.reduce((sum, [, v]) => sum + v, 0) || 1;
for (const [label, value, color] of parts) {
if (value <= 0) continue;
const seg = el("span");
seg.style.width = (value / shown * 100).toFixed(2) + "%";
seg.style.background = color;
seg.title = label + ": " + tokens(value);
bar.appendChild(seg);
const item = el("div", "item");
const swatch = el("span", "swatch");
swatch.style.background = color;
item.appendChild(swatch);
item.appendChild(el("span", null, label));
item.appendChild(el("span", "amt", tokens(value)));
legend.appendChild(item);
}
card.appendChild(bar);
card.appendChild(legend);
s.appendChild(card);
return s;
}
const clock = (iso, withDate) => {
const at = new Date(iso);
if (isNaN(at)) return "";
const hm = String(at.getHours()).padStart(2, "0") + ":" +
String(at.getMinutes()).padStart(2, "0");
if (!withDate) return hm;
return at.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + hm;
};
function windowChart(r) {
const series = (r.context && r.context.series) || [];
if (series.length < 3) return null;
const first = series[0].ts, last = series[series.length - 1].ts;
const spansDays = new Date(first).toDateString() !== new Date(last).toDateString();
const s = block(
"How the window filled",
"Left to right, one point per request in the order they were made — evenly spaced, " +
"so this is not a time axis: a wide flat stretch is many requests, not a long pause. " +
"The labels underneath say when each end happened. Up is tokens in the window; " +
"dashed vertical marks are compactions, where the harness reclaimed it."
);
const W = 900, H = 205, PAD_L = 46, PAD_B = 32, PAD_T = 8;
const max = Math.max(...series.map((p) => p.window), 1);
const x = (i) => PAD_L + (i / (series.length - 1)) * (W - PAD_L - 8);
const y = (v) => PAD_T + (1 - v / max) * (H - PAD_T - PAD_B);
const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, width: "100%", height: H,
role: "img",
"aria-label": "Context window size across " + series.length +
" requests, from " + clock(first, true) +
" to " + clock(last, true) });
for (const frac of [0, 0.5, 1]) {
const yy = y(max * frac);
chart.appendChild(svg("line", { x1: PAD_L, y1: yy, x2: W - 8, y2: yy, class: "gridline" }));
const label = svg("text", { x: PAD_L - 6, y: yy + 3, "text-anchor": "end", class: "axis" });
label.textContent = tokens(Math.round(max * frac));
chart.appendChild(label);
}
const foot = (xPos, anchor, text) => {
const t = svg("text", { x: xPos, y: H - 14, "text-anchor": anchor, class: "axis" });
t.textContent = text;
chart.appendChild(t);
};
foot(PAD_L, "start", clock(first, spansDays));
foot((PAD_L + W - 8) / 2, "middle", series.length + " requests →");
foot(W - 8, "end", clock(last, spansDays));
const line = series.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(p.window).toFixed(1)}`).join("");
chart.appendChild(svg("path", {
d: `${line}L${x(series.length - 1).toFixed(1)},${y(0)}L${x(0).toFixed(1)},${y(0)}Z`,
fill: "var(--accent)", "fill-opacity": ".13",
}));
chart.appendChild(svg("path", { d: line, fill: "none", stroke: "var(--accent)", "stroke-width": "1.5" }));
series.forEach((p, i) => {
if (!p.after_compaction) return;
chart.appendChild(svg("line", {
x1: x(i), y1: PAD_T, x2: x(i), y2: y(0),
stroke: "var(--amber)", "stroke-width": "1", "stroke-dasharray": "3 3",
}));
});
const holder = el("div", "card pad scroll-x");
holder.appendChild(chart);
s.appendChild(holder);
return s;
}
function spend(r) {
const byModel = r.cost.by_model || [];
const byHour = r.cost.by_hour || [];
if (!byModel.length && !byHour.length) return null;
const s = block("What it cost");
const card = el("div", "card");
if (byHour.length > 1) {
const W = 900, H = 110, PAD_L = 46, PAD_B = 16;
const max = Math.max(...byHour.map(([, v]) => v), 0.0001);
const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, width: "100%", height: H,
role: "img", "aria-label": "Spend per hour" });
const gap = 1;
const bw = Math.max(1, (W - PAD_L - 8) / byHour.length - gap);
byHour.forEach(([key, value], i) => {
const h = Math.max(1, (value / max) * (H - PAD_B - 4));
const bar = svg("rect", {
x: PAD_L + i * (bw + gap), y: H - PAD_B - h, width: bw, height: h,
fill: "var(--accent)", rx: 1,
});
const tip = svg("title");
tip.textContent = key.replace("T", " ") + ":00 — " + money(value);
bar.appendChild(tip);
chart.appendChild(bar);
});
const top = svg("text", { x: PAD_L - 6, y: 12, "text-anchor": "end", class: "axis" });
top.textContent = money(max);
chart.appendChild(top);
const holder = el("div", "pad scroll-x");
holder.appendChild(chart);
holder.appendChild(el("div", "faint", "Per hour, most recent at the right."));
card.appendChild(holder);
}
if (byModel.length) {
const holder = el("div", "pad scroll-x");
const table = el("table");
const head = el("tr");
for (const [label, cls] of [["Model", ""], ["Input", "num"], ["Output", "num"], ["Cost", "num"]]) {
head.appendChild(el("th", cls, label));
}
table.appendChild(head);
for (const m of byModel) {
const tr = el("tr");
tr.appendChild(el("td", null, m.model));
tr.appendChild(el("td", "num", tokens(m.tokens.input)));
tr.appendChild(el("td", "num", tokens(m.tokens.output)));
tr.appendChild(el("td", "num", r.cost.included ? "incl" : money(m.total)));
table.appendChild(tr);
}
holder.appendChild(table);
card.appendChild(holder);
}
s.appendChild(card);
return s;
}
function slowest(r) {
const calls = r.activity.slowest || [];
if (!calls.length) return null;
const s = block("Slowest calls", "Wall time from the call being issued to its result arriving.");
const card = el("div", "card pad scroll-x");
const table = el("table");
const head = el("tr");
head.appendChild(el("th", null, "Tool"));
head.appendChild(el("th", null, "Call"));
head.appendChild(el("th", "num", "Took"));
table.appendChild(head);
for (const c of calls) {
const tr = el("tr");
tr.appendChild(el("td", "mono", c.tool));
const what = el("td");
what.appendChild(el("span", "mono", c.detail));
if (c.failed) what.appendChild(el("span", "pill bad", "failed"));
if (c.origin) what.appendChild(el("span", "pill", c.origin));
tr.appendChild(what);
tr.appendChild(el("td", "num", secs(c.duration_ms)));
table.appendChild(tr);
}
card.appendChild(table);
s.appendChild(card);
return s;
}
function toolTable(r) {
const tools = r.activity.tools || [];
if (!tools.length) return null;
const s = block("Tools");
const card = el("div", "card pad scroll-x");
const table = el("table");
const head = el("tr");
head.appendChild(el("th", null, "Tool"));
head.appendChild(el("th", "num", "Calls"));
head.appendChild(el("th", "num", "Failed"));
table.appendChild(head);
for (const t of tools) {
const tr = el("tr");
tr.appendChild(el("td", "mono", t.name));
tr.appendChild(el("td", "num", t.calls.toLocaleString()));
tr.appendChild(el("td", "num", t.failed ? String(t.failed) : "—"));
table.appendChild(tr);
}
card.appendChild(table);
s.appendChild(card);
return s;
}
function files(r) {
if (!r.files || !r.files.length) return null;
const s = block("Files it wrote", "Most recent first.");
const card = el("div", "card pad");
const list = el("div", "files");
for (const f of r.files) list.appendChild(el("div", null, f));
card.appendChild(list);
s.appendChild(card);
return s;
}
function heaviest(r) {
const calls = r.activity.heaviest || [];
if (!calls.length) return null;
const shared = calls.some((c) => c.shared > 1);
const s = block(
"What filled the window",
"How much the context window grew after each call — which is what its result " +
"added. Largest first. Measured from the prompt each request was billed for, " +
"not from the result itself, which no transcript records" +
(shared
? ": a turn that issued several calls grew it by all of their results together, " +
"and those are marked."
: ". Anything else that arrived in the same gap — a message you typed while it " +
"worked — is counted here too.")
);
const card = el("div", "card pad scroll-x");
const table = el("table");
const head = el("tr");
head.appendChild(el("th", null, "Tool"));
head.appendChild(el("th", null, "Call"));
head.appendChild(el("th", "num", "Grew by"));
table.appendChild(head);
for (const c of calls) {
const tr = el("tr");
tr.appendChild(el("td", "mono", c.tool));
const what = el("td");
what.appendChild(el("span", "mono", c.detail));
if (c.shared > 1) what.appendChild(el("span", "pill", "with " + (c.shared - 1) + " more"));
if (c.origin) what.appendChild(el("span", "pill", c.origin));
tr.appendChild(what);
tr.appendChild(el("td", "num", tokens(c.window_growth)));
table.appendChild(tr);
}
card.appendChild(table);
s.appendChild(card);
return s;
}
function diffs(r) {
const files = r.diffs || [];
if (!files.length) return null;
const total = files.reduce((a, f) => a + f.added + f.removed, 0);
const s = block(
"What it changed",
files.length + (files.length === 1 ? " file, " : " files, ") + total +
" changed lines. Most-changed first; click a file for its diff. Reconstructed from the patches the transcript recorded, so it is what the agent applied rather than what the file holds now."
);
const card = el("div", "card");
for (const f of files) {
const box = el("details", "diff");
const head = el("summary");
head.appendChild(el("span", "path", f.file));
if (f.added) head.appendChild(el("span", "plus", "+" + f.added));
if (f.removed) head.appendChild(el("span", "minus", "−" + f.removed));
if (f.edits > 1) head.appendChild(el("span", "faint", f.edits + " edits"));
box.appendChild(head);
const pre = el("pre");
for (const line of f.hunks) {
const cls = line.startsWith("+") ? "add"
: line.startsWith("-") ? "del"
: line.startsWith("@@") || line.startsWith("diff ") ? "meta"
: "";
pre.appendChild(el("span", cls, line));
}
if (f.truncated) {
pre.appendChild(el("span", "meta", "… the rest of this file's edits are not kept"));
}
box.appendChild(pre);
card.appendChild(box);
}
s.appendChild(card);
return s;
}
function callLog(r) {
const calls = r.activity.calls || [];
if (!calls.length) return null;
const made = r.activity.tool_count || 0;
const note = calls.length < made
? "Newest first. The transcript keeps only its most recent " + calls.length +
" calls, of " + made + " the session made."
: "Newest first. All " + calls.length + " calls.";
const note2 = calls.some((c) => c.origin)
? " A call a subagent made carries its name; the rest are the session's own."
: "";
const s = block("Every tool call", note + note2);
const card = el("div", "card log");
const table = el("table");
const head = el("thead");
const hrow = el("tr");
hrow.appendChild(el("th", null, "Tool"));
hrow.appendChild(el("th", null, "Call"));
hrow.appendChild(el("th", "num", "Took"));
hrow.appendChild(el("th", "num", "Grew"));
hrow.appendChild(el("th", null, "When"));
head.appendChild(hrow);
table.appendChild(head);
const body = el("tbody");
for (const c of calls) {
const tr = el("tr", c.failed ? "bad" : null);
tr.appendChild(el("td", "mono", c.tool));
const what = el("td");
what.appendChild(el("span", "mono", c.detail));
if (c.failed) what.appendChild(el("span", "pill bad", "failed"));
if (c.origin) what.appendChild(el("span", "pill", c.origin));
tr.appendChild(what);
tr.appendChild(el("td", "num", c.duration_ms == null ? "—" : secs(c.duration_ms)));
const grew = el("td", "num", c.window_growth == null ? "—" : "+" + tokens(c.window_growth));
if (c.shared > 1) grew.title = "shared with " + (c.shared - 1) + " more calls in the same turn";
tr.appendChild(grew);
tr.appendChild(el("td", "when", (c.ts || "").replace("T", " ").replace(/\..*$/, "")));
body.appendChild(tr);
}
table.appendChild(body);
card.appendChild(table);
s.appendChild(card);
return s;
}
function subagents(r) {
if (!r.subagents || !r.subagents.length) return null;
const s = block("Subagents");
const card = el("div", "card pad scroll-x");
const table = el("table");
const head = el("tr");
for (const [label, cls] of [["Type", ""], ["Description", ""], ["Tools", "num"], ["Cost", "num"]]) {
head.appendChild(el("th", cls, label));
}
table.appendChild(head);
for (const a of r.subagents) {
const tr = el("tr");
tr.appendChild(el("td", "mono", a.type));
tr.appendChild(el("td", null, a.description));
tr.appendChild(el("td", "num", a.tool_count));
tr.appendChild(el("td", "num", r.cost.included ? "incl" : money(a.cost)));
table.appendChild(tr);
}
card.appendChild(table);
s.appendChild(card);
return s;
}
function patchLines(hunks) {
const pre = el("pre", "patch");
for (const line of hunks) {
const cls = line.startsWith("+") ? "add" : line.startsWith("-") ? "del"
: line.startsWith("@@") ? "meta" : "";
pre.appendChild(el("span", cls, line));
}
return pre;
}
function toolNode(tool) {
const failed = !!tool.failed;
const running = tool.result === undefined || tool.result === null;
const node = el("details", "tool" + (failed ? " failed" : running ? " running" : ""));
const sum = el("summary");
sum.appendChild(el("span", "name", tool.name));
if (tool.detail) sum.appendChild(el("span", "arg", tool.detail));
if (tool.added || tool.removed) {
sum.appendChild(el("span", "plus", "+" + (tool.added || 0)));
sum.appendChild(el("span", "minus", "−" + (tool.removed || 0)));
}
if (failed) sum.appendChild(el("span", "pill bad", "failed"));
else if (running) sum.appendChild(el("span", "pill warn", "running"));
node.appendChild(sum);
if (tool.full && tool.full !== tool.detail) node.appendChild(el("pre", null, tool.full));
if (tool.diff && tool.diff.length) node.appendChild(patchLines(tool.diff));
if (!running && tool.result !== "") node.appendChild(el("pre", null, tool.result));
return node;
}
const WHO = { user: "you", assistant: "agent", system: "harness" };
const BLOCK = /^\s*(```|~~~|#{1,6}\s|>|[-*+]\s|\d+[.)]\s)/;
const RULE_ROW = /^\s*\|?(\s*:?-+:?\s*\|)+(\s*:?-+:?\s*)?\|?\s*$/;
const BULLET = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
const FENCE = /^\s*(```|~~~)/;
function linkNode(label, href, raw) {
if (!/^(https?:|mailto:)/i.test(href)) return document.createTextNode(raw || label);
const a = el("a", null, label);
a.href = href;
a.target = "_blank";
a.rel = "noopener noreferrer";
return a;
}
const INLINE = /`([^`]+)`|\*\*([\s\S]+?)\*\*|(?<![\w*])\*([^*\n]+)\*(?![\w*])|\[([^\]]*)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()\[\]]+)/g;
function inlineInto(node, text) {
let at = 0;
let m;
INLINE.lastIndex = 0;
while ((m = INLINE.exec(text))) {
if (m.index > at) node.appendChild(document.createTextNode(text.slice(at, m.index)));
if (m[1] !== undefined) node.appendChild(el("code", null, m[1]));
else if (m[2] !== undefined) node.appendChild(el("strong", null, m[2]));
else if (m[3] !== undefined) node.appendChild(el("em", null, m[3]));
else if (m[4] !== undefined) node.appendChild(linkNode(m[4] || m[5], m[5], m[0]));
else node.appendChild(linkNode(m[6], m[6]));
at = INLINE.lastIndex;
}
if (at < text.length) node.appendChild(document.createTextNode(text.slice(at)));
return node;
}
function tableAt(lines, i) {
return i + 1 < lines.length && lines[i].includes("|") && RULE_ROW.test(lines[i + 1]);
}
function cells(line) {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split(/(?<!\\)\|/)
.map((cell) => cell.replace(/\\\|/g, "|").trim());
}
function markdown(text) {
const root = el("div", "md");
const lines = String(text).split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (FENCE.test(line)) {
i++;
const body = [];
while (i < lines.length && !FENCE.test(lines[i])) body.push(lines[i++]);
i++; const pre = el("pre");
pre.appendChild(el("code", null, body.join("\n")));
root.appendChild(pre);
continue;
}
if (!line.trim()) {
i++;
continue;
}
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
if (heading) {
root.appendChild(inlineInto(el("h" + heading[1].length), heading[2].trim()));
i++;
continue;
}
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) {
root.appendChild(el("hr"));
i++;
continue;
}
if (/^\s*>/.test(line)) {
const body = [];
while (i < lines.length && /^\s*>/.test(lines[i])) body.push(lines[i++].replace(/^\s*>\s?/, ""));
const quote = el("blockquote");
quote.appendChild(markdown(body.join("\n")));
root.appendChild(quote);
continue;
}
if (tableAt(lines, i)) {
const head = cells(line);
const align = cells(lines[i + 1]).map((rule) => {
if (/^:-+:$/.test(rule)) return "center";
if (/-+:$/.test(rule)) return "right";
return "";
});
i += 2;
const table = el("table");
const header = el("tr");
head.forEach((cell, n) => {
const th = inlineInto(el("th"), cell);
if (align[n]) th.style.textAlign = align[n];
header.appendChild(th);
});
table.appendChild(el("thead")).appendChild(header);
const body = el("tbody");
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
const row = el("tr");
cells(lines[i++]).forEach((cell, n) => {
const td = inlineInto(el("td"), cell);
if (align[n]) td.style.textAlign = align[n];
row.appendChild(td);
});
body.appendChild(row);
}
table.appendChild(body);
const scroll = el("div", "scroll");
scroll.appendChild(table);
root.appendChild(scroll);
continue;
}
if (BULLET.test(line)) {
const list = el(/^\s*\d/.test(line) ? "ol" : "ul");
while (i < lines.length && BULLET.test(lines[i])) {
const m = lines[i++].match(BULLET);
const said = [m[3]];
while (i < lines.length && lines[i].trim() && !BULLET.test(lines[i]) && !FENCE.test(lines[i])) {
said.push(lines[i++].trim());
}
list.appendChild(inlineInto(el("li"), said.join(" ")));
}
root.appendChild(list);
continue;
}
const para = [lines[i++]];
while (i < lines.length && lines[i].trim() && !BLOCK.test(lines[i]) && !tableAt(lines, i)) {
para.push(lines[i++]);
}
root.appendChild(inlineInto(el("p"), para.join("\n")));
}
return root;
}
function turnNode(turn) {
const kind = turn.kind === "message" ? "" : " " + turn.kind;
const node = el("div", "turn " + turn.role + kind);
if (turn.kind === "compaction") {
node.appendChild(el("div", "said-text", "— context compacted here —"));
return node;
}
const who = el("div", "who");
who.appendChild(el("span", null, turn.kind === "reasoning" ? "thinking" : (WHO[turn.role] || turn.role)));
if (turn.ts) who.appendChild(el("span", "faint", clock(turn.ts, true) || ""));
node.appendChild(who);
if (turn.text) node.appendChild(markdown(turn.text));
if (turn.clipped) node.appendChild(el("div", "clip", "… cut for length; the whole of it is in the transcript"));
for (const tool of turn.tools || []) node.appendChild(toolNode(tool));
return node;
}
function conversation(chat) {
const s = block("Conversation");
if (!chat.supported) {
s.appendChild(el("div", "card pad faint", chat.note || "no reader for this harness"));
return s;
}
const card = el("div", "card");
if (chat.earlier) {
card.appendChild(el("div", "turn faint", chat.earlier + " earlier turns are in the transcript and not shown"));
}
if (!chat.turns.length) {
card.appendChild(el("div", "empty", "Nothing has been said in this session yet."));
}
for (const turn of chat.turns) card.appendChild(turnNode(turn));
s.appendChild(card);
return s;
}
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. The link is up but nothing is behind it — the terminal it runs in may have stopped.";
if (response.status === 401 || response.status === 403) return "This link is no longer authorised. Open a fresh one from the terminal.";
return "The server answered " + response.status + (response.statusText ? " " + response.statusText : "") + ".";
}
async function asJson(response) {
const text = await response.text();
try {
return JSON.parse(text);
} catch (e) {
throw new Error(
"cctop answered with something that is not its own: whatever replied to " +
"this page, it was not the session it was asked about."
);
}
}
async function ask(url, init) {
let response;
try {
response = await fetch(url, init);
} catch (e) {
throw new Error("cctop is unreachable — the connection dropped.");
}
if (!response.ok) throw new Error(await problem(response));
return response;
}
function offline(why) {
const main = document.getElementById("main");
let banner = document.getElementById("offline");
if (!why) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = el("div", "banner");
banner.id = "offline";
main.prepend(banner);
}
banner.textContent = why + " Still trying.";
}
async function act(verb, body) {
const response = await ask("/api/act/" + verb + "/" + encodeURIComponent(ID) + QUERY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
const text = (await response.text()).trim();
try { return JSON.parse(text); } catch (e) { return { message: text }; }
}
function report_said(text, ok) {
const node = document.getElementById("said");
if (!node) return;
node.className = "said " + (ok ? "ok" : "bad");
node.textContent = text;
}
async function run(button, verb, body, verb_label) {
const was = button.textContent;
button.disabled = true;
button.textContent = "…";
report_said("", true);
try {
const done = await act(verb, body);
report_said(done.message || (verb_label + " done"), true);
} catch (e) {
report_said(String(e.message || e), false);
} finally {
button.disabled = false;
button.textContent = was;
}
}
function actionBar(r, agents) {
if (!CAN_ACT) return null;
const bar = el("div", "acts");
const resume = el("button", null, r.running ? "Reattach" : "Resume");
resume.title = "Start this session's own harness back up on this transcript, in tmux";
resume.addEventListener("click", () => run(resume, "resume", {}, "Resume"));
bar.appendChild(resume);
if (agents.length) {
const pick = el("select");
pick.title = "Which agent takes this session's work";
for (const agent of agents) {
const option = el("option", null, agent);
option.value = agent;
pick.appendChild(option);
}
const hand = el("button", null, "Hand off →");
hand.title = "Write a brief of this session and start the chosen agent on it, here";
hand.addEventListener("click", () => run(hand, "handoff", { agent: pick.value }, "Handoff"));
bar.appendChild(pick);
bar.appendChild(hand);
}
return bar;
}
function composer(r) {
if (!CAN_ACT) return null;
const form = el("form", "say");
form.id = "say";
const input = el("input");
input.type = "text";
input.maxLength = 4000;
input.autocomplete = "off";
input.placeholder = r.running ? "Answer this session…" : "Nothing is running this session — resume it first";
input.disabled = !r.running;
const send = el("button", null, "Send");
send.type = "submit";
send.disabled = !r.running;
form.appendChild(input);
form.appendChild(send);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
send.disabled = true;
try {
const done = await act("send", { text });
report_said(done.message || "Sent", true);
input.value = "";
setTimeout(refreshChat, 1200);
} catch (e) {
report_said(String(e.message || e), false);
} finally {
send.disabled = false;
input.focus();
}
});
return form;
}
function fileRules(heading, files) {
if (!files || !files.length) return null;
const s = block(heading);
const card = el("div", "card");
for (const file of files) {
const node = el("details", "rule");
node.dataset.present = String(!!file.present);
const sum = el("summary");
sum.appendChild(el("span", "path", file.path));
sum.appendChild(el("span", "pill", file.scope));
sum.appendChild(el("span", "faint", file.present ? bytes(file.bytes) : "not there"));
node.appendChild(sum);
if (file.present) {
node.appendChild(el("pre", null, (file.head || "") + (file.clipped ? "\n\n… cut for length" : "")));
}
card.appendChild(node);
}
s.appendChild(card);
return s;
}
const bytes = (n) => {
const v = Number(n) || 0;
if (v >= 1048576) return (v / 1048576).toFixed(1) + " MB";
if (v >= 1024) return Math.round(v / 1024) + " KB";
return v + " B";
};
function accessView(a) {
const parts = [];
const s = block("In scope", "The files and servers that apply to a session in this directory — which is checkable. What the harness actually loaded is its own business, and this does not claim to know it.");
const card = el("div", "card pad");
const dl = el("dl", "kv");
const row = (k, v) => { if (v) { dl.appendChild(el("dt", null, k)); dl.appendChild(el("dd", null, v)); } };
row("Directory", a.cwd + (a.cwd_exists ? "" : " (gone)"));
row("Branch", a.branch);
row("Harness", a.harness);
row("Model", a.model);
if (a.permission) row("Permission", a.permission + " — " + (a.permission_detail || ""));
if (a.pid) row("Process", "pid " + a.pid);
card.appendChild(dl);
if (a.note) card.appendChild(el("p", "note", a.note));
s.appendChild(card);
parts.push(s);
parts.push(fileRules("Instructions", a.instructions));
parts.push(fileRules("Settings", a.configs));
if (a.mcp && a.mcp.length) {
const m = block("MCP servers", "Tools reaching outside this machine's filesystem come from here.");
const card = el("div", "card pad scroll-x");
const table = el("table");
const head = el("tr");
for (const label of ["Server", "Scope", "Command"]) head.appendChild(el("th", null, label));
table.appendChild(head);
for (const server of a.mcp) {
const tr = el("tr");
tr.appendChild(el("td", "mono", server.name));
tr.appendChild(el("td", null, server.scope));
tr.appendChild(el("td", "mono dim", server.command || "—"));
table.appendChild(tr);
}
card.appendChild(table);
m.appendChild(card);
parts.push(m);
}
if (a.skills && a.skills.length) {
const k = block("Skills", a.skills_dir || "");
const card = el("div", "card");
for (const skill of a.skills) {
const node = el("div", "rule");
const line = el("div", null, null);
line.style.padding = "9px 14px";
line.appendChild(el("span", "mono", skill.name));
if (skill.description) {
line.appendChild(el("span", "faint", " " + skill.description));
}
node.appendChild(line);
card.appendChild(node);
}
k.appendChild(card);
parts.push(k);
}
if (a.tools && a.tools.length) {
const t = block("Tools it has used", "What it reached for, not what it was offered.");
const card = el("div", "card pad");
const chips = el("div", "chips");
for (const tool of a.tools) {
const chip = el("span", "chip", tool.name);
chip.appendChild(el("span", "n", tool.count));
chips.appendChild(chip);
}
card.appendChild(chips);
t.appendChild(card);
parts.push(t);
}
if (a.hooks && a.hooks.length) {
const h = block("cctop's own hooks", "Whether this harness reports its state to cctop as it happens, rather than being read off disk after the fact.");
const card = el("div", "card pad scroll-x");
const table = el("table");
const head = el("tr");
for (const label of ["Harness", "Scope", "State", "File"]) head.appendChild(el("th", null, label));
table.appendChild(head);
for (const hook of a.hooks) {
const tr = el("tr");
tr.appendChild(el("td", null, hook.harness));
tr.appendChild(el("td", null, hook.scope));
const state = el("td");
const cls = hook.state === "installed" ? "" : hook.state === "absent" ? "" : hook.state === "broken" ? "bad" : "warn";
state.appendChild(el("span", "pill " + cls, hook.state));
if (hook.detail) state.appendChild(el("span", "faint", " " + hook.detail));
tr.appendChild(state);
tr.appendChild(el("td", "mono dim", hook.path));
table.appendChild(tr);
}
card.appendChild(table);
h.appendChild(card);
parts.push(h);
}
if (a.writes && a.writes.length) {
const w = block("Written lately");
const card = el("div", "card pad");
const files = el("div", "files");
for (const path of a.writes) files.appendChild(el("div", null, path));
card.appendChild(files);
w.appendChild(card);
parts.push(w);
}
return parts.filter(Boolean);
}
const PANES = {};
let CHAT_TIMER = null;
function show(name) {
for (const [key, pane] of Object.entries(PANES)) pane.hidden = key !== name;
for (const button of document.querySelectorAll("nav.views button")) {
button.setAttribute("aria-selected", String(button.dataset.view === name));
}
location.hash = name;
}
async function refreshChat() {
const pane = document.getElementById("talk");
if (!pane) return;
try {
const response = await ask("/api/chat/" + encodeURIComponent(ID) + QUERY);
const chat = await asJson(response);
const rebuilt = conversation(chat);
const say = pane.querySelector("form.say");
pane.replaceChildren(rebuilt);
if (say) pane.appendChild(say);
offline(null);
} catch (e) {
if (pane.querySelector(".card")) offline(String(e.message || e));
else pane.replaceChildren(el("div", "empty", String(e.message || e)));
}
}
function load() {
ask("/api/report/" + encodeURIComponent(ID) + QUERY)
.then(asJson)
.then(async (r) => {
document.title = "cctop — " + (r.title || r.project || r.session_id);
const main = document.getElementById("main");
main.replaceChildren();
main.appendChild(subject(r));
let agents = [];
if (CAN_ACT) {
try {
agents = (await asJson(await ask("/api/agents" + QUERY))).agents || [];
} catch (e) { }
}
const bar = actionBar(r, agents);
if (bar) {
main.appendChild(bar);
const said = el("div", "said", "");
said.id = "said";
main.appendChild(said);
}
const nav = el("nav", "views");
main.appendChild(nav);
if (r.error) main.appendChild(el("div", "banner", "This transcript could not be fully read: " + r.error));
for (const name of ["chat", "changes", "access", "report"]) {
const pane = el("div");
pane.hidden = true;
PANES[name] = pane;
main.appendChild(pane);
}
const split = el("div", "split");
split.id = "split";
const talk = el("div", "talk");
talk.id = "talk";
const side = el("aside", "termside");
side.id = "termside";
side.hidden = true;
split.append(talk, side);
PANES.chat.appendChild(split);
PANES.changes.replaceChildren(
...[diffs, files].map((part) => part(r)).filter(Boolean)
);
PANES.report.replaceChildren(
tiles(r),
...[failures, breakdown, windowChart, heaviest, spend, slowest, toolTable, callLog, subagents]
.map((part) => part(r))
.filter(Boolean)
);
const labels = {
chat: ["Conversation", ""],
changes: ["Changes", r.diffs ? r.diffs.length : 0],
access: ["Access", ""],
report: ["Report", ""],
};
for (const [name, [label, count]] of Object.entries(labels)) {
const button = el("button", null, label);
button.dataset.view = name;
button.setAttribute("aria-selected", "false");
if (count) button.appendChild(el("span", "n", count));
button.addEventListener("click", () => open_view(name, r));
nav.appendChild(button);
}
if (CAN_ACT) nav.appendChild(terminalButton());
const wanted = location.hash.replace(/^#/, "");
open_view(labels[wanted] ? wanted : "chat", r);
})
.catch((e) => {
document.getElementById("main").replaceChildren(
el("div", "empty", String(e.message || e) + " Trying again…")
);
setTimeout(load, 5000);
});
}
load();
function terminalButton() {
const button = el("button", "toggle", "Terminal");
button.type = "button";
button.title = "Show this agent's own terminal beside the conversation";
button.setAttribute("aria-pressed", "false");
button.addEventListener("click", () => toggleTerminal(button));
return button;
}
function closeTerminal(button) {
const side = document.getElementById("termside");
if (side) {
side.replaceChildren();
side.hidden = true;
}
const split = document.getElementById("split");
if (split) split.classList.remove("with-term");
button.setAttribute("aria-pressed", "false");
}
async function toggleTerminal(button) {
const side = document.getElementById("termside");
const split = document.getElementById("split");
if (!side || !split) return;
if (button.getAttribute("aria-pressed") === "true") return closeTerminal(button);
button.setAttribute("aria-pressed", "true");
button.disabled = true;
side.hidden = false;
split.classList.add("with-term");
side.replaceChildren(el("div", "empty", "Opening this agent's terminal…"));
try {
const terminal = await act("terminal", {});
const head = el("div", "head");
head.appendChild(el("span", null, "This session's terminal"));
const close = el("button", null, "Close");
close.type = "button";
close.addEventListener("click", () => closeTerminal(button));
head.appendChild(close);
const card = el("div", "card pad termcard");
const open = el("a", "open", "Open the terminal");
open.href = terminal.url;
open.target = "_blank";
open.rel = "noopener";
open.addEventListener("click", (ev) => {
const win = window.open(
terminal.url, "cctop-terminal",
"popup,noopener,width=1024,height=720,left=" +
Math.max(0, screen.availWidth - 1044) + ",top=60"
);
if (win) ev.preventDefault();
});
card.appendChild(open);
card.appendChild(el(
"div", "why",
"It opens in a window of its own, which you can put beside this one: " +
"rmux's terminal page refuses to be framed, so it cannot be drawn inside."
));
card.appendChild(el(
"div", "why",
terminal.tunnelled
? "It reaches this machine through rmux's own tunnel, so it works from wherever you are."
: "It is served from the machine cctop runs on, so it only opens from a browser on it."
));
card.appendChild(el(
"div", "why bad",
"Whoever opens that link can type into this agent — it is a shell, not a prompt box."
));
side.replaceChildren(head, card);
} catch (e) {
closeTerminal(button);
report_said(String(e.message || e), false);
} finally {
button.disabled = false;
}
}
async function open_view(name, r) {
show(name);
if (name === "chat" && !PANES.chat.dataset.loaded) {
PANES.chat.dataset.loaded = "1";
document.getElementById("talk").replaceChildren(el("div", "empty", "Reading the conversation…"));
await refreshChat();
const say = composer(r);
if (say) document.getElementById("talk").appendChild(say);
if (r.running && !CHAT_TIMER) CHAT_TIMER = setInterval(refreshChat, 5000);
}
if (name === "access" && !PANES.access.dataset.loaded) {
PANES.access.dataset.loaded = "1";
PANES.access.replaceChildren(el("div", "empty", "Reading what it can reach…"));
try {
const response = await ask("/api/access/" + encodeURIComponent(ID) + QUERY);
PANES.access.replaceChildren(...accessView(await asJson(response)));
} catch (e) {
delete PANES.access.dataset.loaded;
PANES.access.replaceChildren(el("div", "empty", String(e.message || e)));
}
}
}
</script>