usage: Sessions — Claude Code's and Codex's own session files read into a list beside the overview: each session's folder, first prompt, models, tokens and estimated cost at list price, filtered by agent and searched; Resume copies the cd and claude --resume / codex resume that picks it up, and the Mac app can open it in Terminal. Only the latest 200 are read, cached by size and time, nothing written to the agents' folders

This commit is contained in:
yetone committed 2026-09-27 17:47:14 +08:00
1 parent 0ce1888a32
commit 78fe45dbc3
15 files changed
+1651 -15

No files matched your search

+1
View File
@@ -294,6 +294,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
providerRoutes(mux, w)
importRoutes(mux)
usageRoutes(mux)
sessionRoutes(mux, w)
backupRoutes(mux, w)
libraryRoutes(mux, w)
updateRoutes(mux, w)
+41
View File
@@ -456,6 +456,47 @@ body.window .field.solo { grid-column: auto; }
.row.stat .num small { font-size: 10.5px; color: var(--muted); white-space: nowrap; }
.row.stat .cost { flex: none; min-width: 58px; text-align: right; color: var(--green); font-variant-numeric: tabular-nums; font-size: 12px; }
.usage-note { padding: 10px 4px 2px; font-size: 11px; color: var(--faint); user-select: text; -webkit-user-select: text; }
.usage-pane { display: flex; flex-direction: column; flex: none; }
.usage-pane[hidden] { display: none; }
/* ---- sessions, a segment of usage ------------------------------------------ */
.sess-tools { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; flex: none; }
.sess-tools .grow { flex: 1; }
.sess-filter { width: 220px; max-width: 100%; height: 26px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font-size: 12px; outline: none; }
.sess-filter:focus { border-color: var(--accent); }
.sess-list { margin-top: 8px; }
.sess-sk { flex-direction: column; align-items: stretch; justify-content: center; gap: 7px; min-height: 48px; }
.sess-item + .sess-item { border-top: 1px solid var(--line-2); }
.sess-item.open { background: color-mix(in srgb, var(--pill) 45%, transparent); }
.row.sess { cursor: default; min-height: 46px; }
.row.sess:hover { background: var(--pill); }
.row.sess .num { display: flex; flex-direction: column; align-items: flex-end; flex: none; min-width: 64px; font-variant-numeric: tabular-nums; }
.row.sess .num b { font-weight: 600; }
.row.sess .num small { font-size: 10.5px; color: var(--muted); white-space: nowrap; }
.row.sess .cost { flex: none; min-width: 58px; text-align: right; color: var(--green); font-variant-numeric: tabular-nums; font-size: 12px; }
.row.sess .cost.none { color: var(--faint); }
.sess-resume { flex: none; height: 24px; padding: 0 10px; border-radius: 7px; border: 0; background: var(--pill); color: var(--fg); font-size: 11.5px; font-weight: 500; white-space: nowrap; }
.sess-resume:hover { background: var(--pill-hover); }
.sess-resume.done { background: var(--green-soft); color: var(--green); }
.sess-term { flex: none; }
.sess-detail { display: flex; flex-direction: column; gap: 5px; padding: 2px 12px 11px 38px; font-size: 11.5px; user-select: text; -webkit-user-select: text; }
.sess-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sess-line .k { flex: 0 0 64px; color: var(--muted); }
.sess-line .v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-2); }
.sess-line code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--code); font-size: 11px; padding: 2px 6px; border-radius: 5px; background: var(--pill); }
.sess-line .copy { flex: none; }
.sess-models { display: grid; grid-template-columns: minmax(0, auto) 1fr auto; gap: 3px 12px; margin: 3px 0 2px 72px; font-variant-numeric: tabular-nums; }
.sess-models .model { font-family: var(--code); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sess-models .n { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess-models .c { text-align: right; color: var(--green); }
.sess-models .c.none { color: var(--faint); }
@media (max-width: 560px) {
.row.sess .num small { display: none; }
.sess-filter { width: 150px; }
.sess-models { margin-left: 0; grid-template-columns: minmax(0, 1fr) auto; }
.sess-models .n { display: none; }
.sess-detail { padding-left: 12px; }
}
/* ---- the panel's usage, a line per account ------------------------------- */
.panel-quota { flex: none; padding: 6px 4px 2px; }
+252 -2
View File
@@ -3619,6 +3619,8 @@ const PERIODS = [["today", "Today"], ["7d", "7 days"], ["30d", "30 days"], ["all
// never waits for them. They don't depend on the period either.
let quotas = null;
async function loadUsage() {
renderUsageTab();
if (usageTab === "sessions") return loadSessions();
renderUsageLoading();
loadQuotas();
usage = await api("usage?period=" + period);
@@ -3944,6 +3946,248 @@ function renderUsage() {
$("#usageNote").textContent = t("Counted from the providers' own usage reports on every call through the gateway · {path}", { path: u.path });
}
// ---------- sessions ----------
//
// The agents' own sessions, read from their session files: what each cost,
// and the command that picks it up again. A segment of the Usage page.
const USAGE_TABS = [["usage", "Overview"], ["sessions", "Sessions"]];
let usageTab = "usage";
try { if (localStorage.getItem("magpie.usageTab") === "sessions") usageTab = "sessions"; } catch {}
let sessions = null; // { sessions, terminal, dirs }
let sessAgent = "all";
let sessQuery = "";
const sessOpen = new Set(); // agent:id of the sessions opened to their details
function renderUsageTab() {
const seg = $("#usageTab");
seg.replaceChildren();
for (const [id, name] of USAGE_TABS) {
const b = el("button", "opt" + (id === usageTab ? " on" : ""), t(name));
b.onclick = () => {
if (id === usageTab) return;
usageTab = id;
try { localStorage.setItem("magpie.usageTab", id); } catch {}
$("#usageCost").replaceChildren();
loadUsage().catch((e) => status(e.message, "err"));
};
seg.append(b);
}
slide(seg, "usageTab");
const on = usageTab === "sessions";
$("#period").hidden = on;
$("#usagePane").hidden = on;
$("#sessionsPane").hidden = !on;
}
async function loadSessions() {
if (!sessions) renderSessionsLoading();
const s = await api("sessions");
if (sessions && JSON.stringify(s) === JSON.stringify(sessions)) return;
sessions = s;
if (view === "usage" && usageTab === "sessions") renderSessions();
}
function renderSessionsLoading() {
const view = $("#view-usage");
view.classList.add("loading");
view.setAttribute("aria-busy", "true");
$("#usageCost").replaceChildren(el("span", "skeleton sk-cost"));
$("#sessAgent").replaceChildren();
const stats = $("#sessStats");
stats.classList.remove("empty");
stats.replaceChildren();
for (let i = 0; i < 4; i++) {
const tile = el("div", "kpi loading-kpi");
tile.append(el("span", "skeleton sk-number"), el("span", "skeleton sk-label"));
stats.append(tile);
}
const list = $("#sessList");
list.hidden = false;
list.replaceChildren();
for (let i = 0; i < 4; i++) {
const r = el("div", "row sess-sk");
r.append(el("span", "skeleton sk-title"), el("span", "skeleton sk-line short"));
list.append(r);
}
$("#sessNote").textContent = t("Reading the agents' session files…");
}
const sessKey = (s) => s.agent + ":" + s.id;
const sessTokens = (s) => s.input + s.output;
// a session's cost: "—" when none of its models has a known price
function sessCost(s) {
if (!s.models.some((m) => m.priced && (m.input || m.output || m.cache_read || m.cache_write))) return "—";
return "≈" + fmtCost({ cost: s.cost, unpriced: s.unpriced });
}
function ago(when) {
const sec = (new Date(when) - Date.now()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale === "zh" ? "zh-CN" : "en", { numeric: "auto" });
for (const [unit, n] of [["year", 31536000], ["month", 2592000], ["week", 604800], ["day", 86400], ["hour", 3600], ["minute", 60]]) {
if (Math.abs(sec) >= n) return rtf.format(Math.round(sec / n), unit);
}
return t("just now");
}
function stamp(when) {
return new Date(when).toLocaleString(locale === "zh" ? "zh-CN" : undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
const baseName = (p) => (p || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() || p;
function renderSessions() {
const view = $("#view-usage");
view.classList.remove("loading");
view.removeAttribute("aria-busy");
const all = sessions?.sessions || [];
// one segment per agent that has sessions
const agents = [...new Map(all.map((s) => [s.agent, s.name])).entries()];
if (sessAgent !== "all" && !agents.some(([id]) => id === sessAgent)) sessAgent = "all";
const seg = $("#sessAgent");
seg.replaceChildren();
seg.hidden = agents.length < 2;
for (const [id, name] of [["all", t("All")], ...agents]) {
const b = el("button", "opt" + (id === sessAgent ? " on" : ""), name);
b.onclick = () => { sessAgent = id; renderSessions(); };
seg.append(b);
}
slide(seg, "sessAgent");
const q = sessQuery.trim().toLowerCase();
const list = all.filter((s) => (sessAgent === "all" || s.agent === sessAgent) &&
(!q || [s.title, s.cwd, s.id, s.name, ...s.models.map((m) => m.model)].some((x) => (x || "").toLowerCase().includes(q))));
// the total of what is listed
const tot = { input: 0, output: 0, cache_read: 0, cache_write: 0, cost: 0, unpriced: 0 };
for (const s of list) {
for (const k of ["input", "output", "cache_read", "cache_write", "cost"]) tot[k] += s[k];
if (sessCost(s) === "—" ? sessTokens(s) : s.unpriced) tot.unpriced++;
}
const cost = $("#usageCost");
cost.replaceChildren();
cost.title = "";
const c = tot.cost ? fmtCost(tot) : "";
if (c) {
cost.append(el("b", "", "≈" + c), el("span", "", t("list price")));
cost.title = tot.unpriced ? t(tot.unpriced === 1 ? "{n} session used a model with no known price; its share is not counted" : "{n} sessions used a model with no known price; their share is not counted", { n: tot.unpriced }) : t("At each model's list price on models.dev");
}
const stats = $("#sessStats");
stats.replaceChildren();
const box = $("#sessList");
box.replaceChildren();
if (!list.length) {
stats.classList.add("empty");
stats.append(el("div", "none", all.length ? t("No session matches.") : t("No sessions yet. Claude Code's and Codex's sessions on this computer show up here, with what each cost and the command that resumes it.")));
box.hidden = true;
} else {
stats.classList.remove("empty");
box.hidden = false;
const tile = (n, label, sub, title) => {
const e = el("div", "kpi");
if (title) e.title = title;
e.append(el("b", "", n), el("span", "", label));
if (sub) e.append(el("small", "", sub));
stats.append(e);
};
const projects = new Set(list.map((s) => s.cwd).filter(Boolean)).size;
tile(String(list.length), t(list.length === 1 ? "session" : "sessions"), t(projects === 1 ? "{n} folder" : "{n} folders", { n: projects }));
tile(fmtN(tot.input + tot.output), t("tokens"), t("{a} in · {b} out", { a: fmtN(tot.input), b: fmtN(tot.output) }));
const prompt = tot.input + tot.cache_read;
tile(fmtN(tot.cache_read), t("cache read"), tot.cache_read && prompt ? t("hit rate {p}", { p: Math.round(100 * tot.cache_read / prompt) + "%" }) : "", tot.cache_write ? t("{n} written", { n: fmtN(tot.cache_write) }) : "");
tile(c ? "≈" + c : "—", t("cost"), t("at list price"));
for (const s of list) box.append(sessionItem(s));
}
const dirs = (sessions?.dirs || []).join(" · ");
$("#sessNote").textContent = t("Read from the agents' own session files, the latest {n} by activity · {dirs}", { n: all.length, dirs });
}
function sessionItem(s) {
const key = sessKey(s);
const item = el("div", "sess-item" + (sessOpen.has(key) ? " open" : ""));
const r = el("div", "row sess");
r.append(icon(s.icon || "generic"));
const who = el("div", "who");
who.append(el("div", "name", s.title || t("(no prompt)")));
const sub = el("div", "sub", [s.cwd ? baseName(s.cwd) : "", s.models.slice(0, 2).map((m) => m.model).join(", ") + (s.models.length > 2 ? " +" + (s.models.length - 2) : ""), ago(s.last)].filter(Boolean).join(" · "));
sub.title = s.cwd || "";
who.append(sub);
r.append(who);
const num = el("div", "num");
num.append(el("b", "", fmtN(sessTokens(s))), el("small", "", t("{a} in · {b} out", { a: fmtN(s.input), b: fmtN(s.output) }) + (s.cache_read ? " · " + t("{n} cached", { n: fmtN(s.cache_read) }) : "")));
r.append(num);
const sc = sessCost(s);
const cost = el("div", "cost" + (sc === "—" ? " none" : ""), sc);
if (sc === "—") cost.title = t("No known price for {models}", { models: s.models.map((m) => m.model).join(", ") || "—" });
else if (s.unpriced) cost.title = t("Not counted: {models}, with no known price", { models: s.models.filter((m) => !m.priced).map((m) => m.model).join(", ") });
r.append(cost);
if (s.resume) {
const res = el("button", "sess-resume", t("Resume"));
res.title = t("Copy the command that resumes it: {cmd}", { cmd: s.resume });
res.onclick = async (ev) => {
ev.stopPropagation();
await copy(s.resume, t("Resume command"));
res.textContent = t("Copied");
res.classList.add("done");
clearTimeout(res.copiedT);
res.copiedT = setTimeout(() => { res.textContent = t("Resume"); res.classList.remove("done"); }, 1400);
};
r.append(res);
if (sessions?.terminal) {
const term = el("button", "copy sess-term");
term.title = t("Open in Terminal");
term.append(svg("M3 4.5 6 7.5 3 10.5M7.5 11.5h5.5", 13, 1.6));
term.onclick = (ev) => {
ev.stopPropagation();
api("sessions/terminal", { agent: s.agent, id: s.id }).then(() => status(t("Opened in Terminal"), "ok"), (e) => status(e.message, "err"));
};
r.append(term);
}
}
r.onclick = () => {
if (window.getSelection()?.toString()) return;
if (sessOpen.has(key)) sessOpen.delete(key); else sessOpen.add(key);
item.replaceWith(sessionItem(s));
};
item.append(r);
if (sessOpen.has(key)) item.append(sessionDetail(s));
return item;
}
function sessionDetail(s) {
const d = el("div", "sess-detail");
const line = (label, value, extra) => {
const l = el("div", "sess-line");
l.append(el("span", "k", label));
const v = el("span", "v", value);
l.append(v);
if (extra) l.append(extra);
d.append(l);
};
line(t("When"), stamp(s.start) + " – " + stamp(s.last));
if (s.cwd) line(t("Folder"), s.cwd);
line(t("Session"), s.id, copyBtn(s.id, t("Session id")));
if (s.resume) {
const code = el("code", "", s.resume);
const l = el("div", "sess-line");
l.append(el("span", "k", t("Resume")), code, copyBtn(s.resume, t("Resume command")));
d.append(l);
}
if (s.models.length) {
const m = el("div", "sess-models");
for (const x of s.models) {
m.append(el("span", "model", x.model),
el("span", "n", t("{a} in · {b} out", { a: fmtN(x.input), b: fmtN(x.output) }) + (x.cache_read ? " · " + t("{n} cached", { n: fmtN(x.cache_read) }) : "") + (x.cache_write ? " · " + t("{n} written", { n: fmtN(x.cache_write) }) : "")),
el("span", "c" + (x.priced ? "" : " none"), x.priced ? "≈" + fmtCost({ cost: x.cost }) : "—"));
}
d.append(m);
}
line(t("File"), s.path);
return d;
}
$("#sessQ").oninput = (e) => { sessQuery = e.target.value; if (sessions) renderSessions(); };
$("#sessQ").onkeydown = (e) => { if (e.key === "Escape" && e.target.value) { e.stopPropagation(); e.target.value = ""; sessQuery = ""; if (sessions) renderSessions(); } };
// ---------- settings ----------
//
// Three choices (palette, language, what the tray icon opens) and the facts
@@ -4581,12 +4825,18 @@ setInterval(async () => {
// vendors' answers are cached behind them) — redrawn only on a change
let usageTicks = 0;
setInterval(async () => {
if (view !== "usage" || document.hidden || !usage || document.querySelector(".pop:not([hidden])")) return;
if (view !== "usage" || document.hidden || document.querySelector(".pop:not([hidden])")) return;
if (usageTab === "sessions") {
// the agents write their session files as they go
if (++usageTicks % 3 === 0 && sessions) loadSessions().catch(() => {});
return;
}
if (!usage) return;
if (++usageTicks % 12 === 0) loadQuotas();
const p = period;
let u;
try { u = await api("usage?period=" + p); } catch { return; }
if (view !== "usage" || p !== period || JSON.stringify(u) === JSON.stringify(usage)) return;
if (view !== "usage" || usageTab !== "usage" || p !== period || JSON.stringify(u) === JSON.stringify(usage)) return;
usage = u;
renderUsage();
}, 5000);
+27
View File
@@ -529,6 +529,33 @@ const I18N = {
"{n} calls had no known price and are not counted": "{n} 次调用没有已知价格,未计入",
"At each model's list price on models.dev": "按 models.dev 上各模型的标价计算",
"no price for these models": "这些模型没有价格信息",
// sessions
"Overview": "概览",
"Sessions": "会话",
"Search sessions": "搜索会话",
"Resume": "继续",
"Resume command": "继续会话的命令",
"Copy the command that resumes it: {cmd}": "复制继续这个会话的命令:{cmd}",
"Open in Terminal": "在终端中打开",
"Opened in Terminal": "已在终端中打开",
"Session id": "会话 ID",
"(no prompt)": "(无提示词)",
"session": "个会话",
"sessions": "个会话",
"{n} folder": "{n} 个文件夹",
"{n} folders": "{n} 个文件夹",
"cost": "费用",
"at list price": "按标价",
"just now": "刚刚",
"No session matches.": "没有匹配的会话。",
"No sessions yet. Claude Code's and Codex's sessions on this computer show up here, with what each cost and the command that resumes it.": "还没有会话。这台电脑上 Claude Code 和 Codex 的会话会显示在这里,附带各自的费用和继续会话的命令。",
"Reading the agents' session files…": "正在读取 Agent 的会话文件…",
"Read from the agents' own session files, the latest {n} by activity · {dirs}": "读取自 Agent 自己的会话文件,按最近活动取 {n} 个 · {dirs}",
"No known price for {models}": "{models} 没有已知价格",
"Not counted: {models}, with no known price": "未计入:{models},没有已知价格",
"{n} session used a model with no known price; its share is not counted": "{n} 个会话用了没有已知价格的模型,这部分未计入",
"{n} sessions used a model with no known price; their share is not counted": "{n} 个会话用了没有已知价格的模型,这部分未计入",
"5 hours": "5 小时",
"24 hours": "24 小时",
"MCP · Month": "MCP · 本月",
+26 -13
View File
@@ -83,24 +83,37 @@
<main class="view" id="view-usage" hidden>
<div class="usage-head">
<div class="segs" id="usageTab"></div>
<div class="segs" id="period"></div>
<span class="grow"></span>
<span class="usage-cost" id="usageCost"></span>
</div>
<div class="row-head quota-head" id="quotaHead" hidden>
<span class="label" data-t>Allowances</span>
<span class="grow"></span>
<button class="text rt-mask" id="usageMask" aria-pressed="false" data-tt="Mask the email addresses on this page, for a screenshot to share"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.2 3.2M6.6 6.6C3.9 8.4 2 12 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2M2 2l20 20"/></svg><span data-t>Hide emails</span></button>
<div class="segs" id="quotaMode" hidden></div>
<div class="usage-pane" id="usagePane">
<div class="row-head quota-head" id="quotaHead" hidden>
<span class="label" data-t>Allowances</span>
<span class="grow"></span>
<button class="text rt-mask" id="usageMask" aria-pressed="false" data-tt="Mask the email addresses on this page, for a screenshot to share"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.2 3.2M6.6 6.6C3.9 8.4 2 12 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2M2 2l20 20"/></svg><span data-t>Hide emails</span></button>
<div class="segs" id="quotaMode" hidden></div>
</div>
<section class="subscription-usage" id="subscriptionUsage"></section>
<section class="stats" id="stats"></section>
<section class="chart" id="chart"></section>
<div class="row-head"><span class="label" data-t>Agents</span></div>
<div class="list" id="usageAgents"></div>
<div class="row-head"><span class="label" data-t>Models</span></div>
<div class="list" id="usageModels"></div>
<p class="usage-note" id="usageNote"></p>
</div>
<div class="usage-pane" id="sessionsPane" hidden>
<div class="sess-tools">
<div class="segs" id="sessAgent"></div>
<span class="grow"></span>
<input class="sess-filter" id="sessQ" type="search" spellcheck="false" autocomplete="off" placeholder="Search sessions" data-tp>
</div>
<section class="stats" id="sessStats"></section>
<div class="list sess-list" id="sessList"></div>
<p class="usage-note" id="sessNote"></p>
</div>
<section class="subscription-usage" id="subscriptionUsage"></section>
<section class="stats" id="stats"></section>
<section class="chart" id="chart"></section>
<div class="row-head"><span class="label" data-t>Agents</span></div>
<div class="list" id="usageAgents"></div>
<div class="row-head"><span class="label" data-t>Models</span></div>
<div class="list" id="usageModels"></div>
<p class="usage-note" id="usageNote"></p>
</main>
<main class="view" id="view-library" hidden></main>
+103
View File
@@ -0,0 +1,103 @@
package gui
import (
"encoding/json"
"errors"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"time"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/sessions"
)
// sessionJSON is a session with what the UI needs to draw its agent.
type sessionJSON struct {
sessions.Session
Name string `json:"name"`
Icon string `json:"icon"`
}
type sessionsJSON struct {
Sessions []sessionJSON `json:"sessions"`
// Terminal is set where magpie can open Terminal on the session: the
// Mac app, not a browser tab that may be on another computer.
Terminal bool `json:"terminal"`
Dirs []string `json:"dirs"` // where they were read from
}
func sessionRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("GET /api/sessions", func(rw http.ResponseWriter, r *http.Request) {
n, _ := strconv.Atoi(r.URL.Query().Get("limit"))
agents := map[string]*agent.Agent{}
for _, a := range agent.Clients() {
agents[a.ID] = a
}
out := sessionsJSON{Sessions: []sessionJSON{}, Terminal: runtime.GOOS == "darwin" && !isWeb(w),
Dirs: []string{tilde(sessions.ClaudeDir()), tilde(sessions.CodexDir())}}
for _, s := range sessions.List(n) {
j := sessionJSON{Session: s, Name: s.Agent, Icon: "generic"}
if a := agents[s.Agent]; a != nil {
j.Name, j.Icon = a.Name, a.Icon
}
j.Path = tilde(j.Path)
out.Sessions = append(out.Sessions, j)
}
writeJSON(rw, out)
})
// terminal opens Terminal on a session's resume command. The command is
// made here from the session as listed, never taken from the page.
mux.HandleFunc("POST /api/sessions/terminal", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Agent, ID string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if runtime.GOOS != "darwin" || isWeb(w) {
fail(rw, errors.New("opening Terminal is only for the Mac app"))
return
}
s, ok := sessions.Find(in.Agent, in.ID)
if !ok || s.Resume == "" {
fail(rw, errors.New("no such session"))
return
}
if err := openTerminal(s.Resume); err != nil {
fail(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
})
}
// openTerminal runs a command in a new Terminal window, through a .command
// file Terminal opens as it would a double-click: no Automation consent.
// The shell is left open when the agent quits.
func openTerminal(command string) error {
f, err := os.CreateTemp("", "magpie-resume-*.command")
if err != nil {
return err
}
script := "#!/bin/sh\n" + command + "\nexec \"${SHELL:-/bin/zsh}\" -l\n"
_, err = f.WriteString(script)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Chmod(f.Name(), 0o700)
}
if err != nil {
os.Remove(f.Name())
return err
}
if err := exec.Command("open", "-a", "Terminal", f.Name()).Run(); err != nil {
os.Remove(f.Name())
return err
}
// Terminal has read it long before
time.AfterFunc(time.Minute, func() { os.Remove(f.Name()) })
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package sessions
import (
"bytes"
"encoding/json"
"regexp"
"strings"
)
// Claude Code writes one line per event: the prompts as "user" lines, each
// block of a reply as an "assistant" line carrying the whole message's
// usage (so a message's later blocks repeat it), and titles of its own.
type ccLine struct {
Type string `json:"type"`
IsMeta bool `json:"isMeta"`
IsSidechain bool `json:"isSidechain"`
Cwd string `json:"cwd"`
SessionID string `json:"sessionId"`
AITitle string `json:"aiTitle"`
Summary string `json:"summary"`
Message struct {
ID string `json:"id"`
Model string `json:"model"`
Content json.RawMessage `json:"content"`
Usage *struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
var (
ccAssistant = []byte(`"type":"assistant"`)
ccUser = []byte(`"type":"user"`)
ccTitle = []byte(`"type":"ai-title"`)
ccSummary = []byte(`"type":"summary"`)
ccCwd = []byte(`"cwd":"`)
)
func claudeLine(s *state, b []byte, main bool) {
s.saw(tsAt(b, true))
want := bytes.Contains(b, ccAssistant) ||
main && s.Title == "" && bytes.Contains(b, ccUser) ||
main && (bytes.Contains(b, ccTitle) || bytes.Contains(b, ccSummary)) ||
s.Cwd == "" && bytes.Contains(b, ccCwd)
if !want {
return
}
var l ccLine
if json.Unmarshal(b, &l) != nil {
return
}
if s.Cwd == "" && l.Cwd != "" {
s.Cwd = l.Cwd
}
if s.ID == "" && l.SessionID != "" {
s.ID = l.SessionID
}
switch l.Type {
case "ai-title":
if l.AITitle != "" {
s.Named = title(l.AITitle)
}
case "summary":
if l.Summary != "" && s.Named == "" {
s.Named = title(l.Summary)
}
case "user":
if main && s.Title == "" && !l.IsMeta && !l.IsSidechain {
s.Title = ccPrompt(l.Message.Content)
if s.Title == "" && s.First == "" {
s.First = untagged(ccText(l.Message.Content))
}
}
case "assistant":
u := l.Message.Usage
if u == nil || l.Message.Model == "<synthetic>" {
return
}
t := Tokens{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite}
if id := l.Message.ID; id != "" && id == s.Msg {
// another block of the message counted: its usage stands for
// the whole, the latest word on it
if m, ok := s.Models[s.MsgModel]; ok {
m.sub(s.MsgUse)
s.Models[s.MsgModel] = m
}
}
s.Msg, s.MsgModel, s.MsgUse = l.Message.ID, l.Message.Model, t
if s.Models == nil {
s.Models = map[string]Tokens{}
}
m := s.Models[l.Message.Model]
m.add(t)
s.Models[l.Message.Model] = m
}
}
// ccText is the text of a user message, "" for a tool's result.
func ccText(content json.RawMessage) string {
var text string
if json.Unmarshal(content, &text) != nil {
var blocks []struct{ Type, Text string }
if json.Unmarshal(content, &blocks) != nil {
return ""
}
var parts []string
for _, b := range blocks {
if b.Type == "tool_result" {
return ""
}
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
}
}
text = strings.Join(parts, " ")
}
return strings.TrimSpace(text)
}
var (
ccCommand = regexp.MustCompile(`<command-name>\s*([^<]*?)\s*</command-name>`)
ccArgs = regexp.MustCompile(`<command-args>\s*([^<]*?)\s*</command-args>`)
)
// ccPrompt is the words of a prompt someone typed, or "" for what Claude
// Code put in the conversation itself (tool results, command output).
func ccPrompt(content json.RawMessage) string {
text := ccText(content)
if m := ccCommand.FindStringSubmatch(text); m != nil {
// a slash command: its name and what was typed after it
cmd := m[1]
if a := ccArgs.FindStringSubmatch(text); a != nil && a[1] != "" {
cmd += " " + a[1]
}
return title(cmd)
}
if text == "" || strings.HasPrefix(text, "<") || strings.HasPrefix(text, "[Request interrupted") || strings.HasPrefix(text, "Caveat:") {
return ""
}
return title(text)
}
+146
View File
@@ -0,0 +1,146 @@
package sessions
import (
"bytes"
"encoding/json"
"strings"
)
// Codex writes a rollout file per session (a session picked up again later
// may go on in another, named with the same thread id): a session_meta line,
// a turn_context per turn naming the model, and token_count events with the
// running total and the last call's usage. Its input counts include the
// cached tokens.
type cxLine struct {
Type string `json:"type"`
Payload struct {
Type string `json:"type"`
ID string `json:"id"`
Cwd string `json:"cwd"`
Model string `json:"model"`
Role string `json:"role"`
Message string `json:"message"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Settings struct {
Model string `json:"model"`
Cwd string `json:"cwd"`
} `json:"thread_settings"`
Info *struct {
Total *cxUsage `json:"total_token_usage"`
Last *cxUsage `json:"last_token_usage"`
} `json:"info"`
} `json:"payload"`
}
type cxUsage struct {
Input int `json:"input_tokens"`
Cached int `json:"cached_input_tokens"`
CacheWrite int `json:"cache_write_input_tokens"`
Output int `json:"output_tokens"`
}
// raw is the usage in Codex's own terms, input with the cache in it.
func (u cxUsage) raw() Tokens {
return Tokens{Input: u.Input, Output: u.Output, CacheRead: u.Cached, CacheWrite: u.CacheWrite}
}
// spent is raw usage with the cache taken out of the input.
func spent(t Tokens) Tokens {
t.Input -= t.CacheRead
if t.Input < 0 {
t.Input = 0
}
return t
}
var (
cxMeta = []byte(`"type":"session_meta"`)
cxTurn = []byte(`"type":"turn_context"`)
cxCount = []byte(`"type":"token_count"`)
cxSettings = []byte(`"type":"thread_settings_applied"`)
cxUserMsg = []byte(`"type":"user_message"`)
cxUserRole = []byte(`"role":"user"`)
)
func codexLine(s *state, b []byte, _ bool) {
s.saw(tsAt(b, false))
want := s.ID == "" && bytes.Contains(b, cxMeta) ||
bytes.Contains(b, cxTurn) || bytes.Contains(b, cxCount) || bytes.Contains(b, cxSettings) ||
s.Title == "" && (bytes.Contains(b, cxUserMsg) || bytes.Contains(b, cxUserRole))
if !want {
return
}
var l cxLine
if json.Unmarshal(b, &l) != nil {
return
}
p := &l.Payload
switch {
case l.Type == "session_meta":
if s.ID == "" {
s.ID = p.ID
}
if s.Cwd == "" {
s.Cwd = p.Cwd
}
case l.Type == "turn_context":
if p.Model != "" {
s.Model = p.Model
}
if s.Cwd == "" {
s.Cwd = p.Cwd
}
case l.Type == "event_msg" && p.Type == "thread_settings_applied":
if p.Settings.Model != "" {
s.Model = p.Settings.Model
}
case l.Type == "event_msg" && p.Type == "user_message":
if s.Title == "" {
s.Title = cxPrompt(p.Message)
}
if s.Title == "" && s.First == "" {
s.First = untagged(p.Message)
}
case l.Type == "response_item" && p.Type == "message" && p.Role == "user":
for _, c := range p.Content {
if s.Title == "" && (c.Type == "input_text" || c.Type == "text") {
s.Title = cxPrompt(c.Text)
}
}
case l.Type == "event_msg" && p.Type == "token_count":
if p.Info == nil || p.Info.Total == nil {
return
}
total := p.Info.Total.raw()
var d Tokens
switch prev := s.Total; {
case prev != nil && total.Input >= prev.Input && total.Output >= prev.Output && total.CacheRead >= prev.CacheRead:
// the same total told again adds nothing
d = total
d.sub(*prev)
d.CacheWrite = max(0, d.CacheWrite)
case p.Info.Last != nil:
// the first count in the file (whose total may run on from an
// earlier file), or a total that started over
d = p.Info.Last.raw()
default:
d = total
}
s.Total = &total
s.use(s.Model, spent(d))
}
}
// cxPrompt is the words of a prompt, or "" for what Codex put in itself
// (the environment, AGENTS.md, instructions).
func cxPrompt(text string) string {
t := strings.TrimSpace(text)
if t == "" || strings.HasPrefix(t, "<") || strings.HasPrefix(t, "# AGENTS.md") {
return ""
}
return title(t)
}
+660
View File
@@ -0,0 +1,660 @@
// Package sessions lists the agents' recent sessions from their own session
// files — Claude Code's projects/*/<id>.jsonl, Codex's rollout files — with
// the tokens each spent, what that cost at list price, and the command that
// resumes it. It only ever reads the agents' folders.
//
// The files grow long (hundreds of MB), so each one's parse is kept by path,
// size and time, and a file that only grew is read on from where it was left.
// The parses are kept on disk too, in magpie's cache folder.
package sessions
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
)
// Tokens is a count of tokens. Input excludes what was read from cache.
type Tokens struct {
Input int `json:"input"`
Output int `json:"output"`
CacheRead int `json:"cache_read"`
CacheWrite int `json:"cache_write"`
}
func (t *Tokens) add(u Tokens) {
t.Input += u.Input
t.Output += u.Output
t.CacheRead += u.CacheRead
t.CacheWrite += u.CacheWrite
}
func (t *Tokens) sub(u Tokens) {
t.Input -= u.Input
t.Output -= u.Output
t.CacheRead -= u.CacheRead
t.CacheWrite -= u.CacheWrite
}
func (t Tokens) zero() bool { return t == Tokens{} }
// Model is one model's share of a session.
type Model struct {
Model string `json:"model"`
Tokens
Cost float64 `json:"cost"`
Priced bool `json:"priced"`
}
// Session is one agent session.
type Session struct {
Agent string `json:"agent"` // magpie agent id: claude, codex
ID string `json:"id"`
Cwd string `json:"cwd"`
Title string `json:"title"` // the first prompt, else the agent's own title
Start time.Time `json:"start"`
Last time.Time `json:"last"`
Models []Model `json:"models"`
Tokens
Cost float64 `json:"cost"` // USD at list price, for the priced models
Unpriced int `json:"unpriced"` // models that spent tokens but have no known price
Resume string `json:"resume"` // the command that picks the session up again
Path string `json:"path"` // its (main) file
}
// PriceOf is the list price of a model as a session names it. Tests swap it.
var PriceOf = priceOf
// Limit is how many sessions, the latest by last activity, List reads.
const Limit = 200
// state is what one file's parse has come to, enough to read on from Off.
type state struct {
Size int64 `json:"size"`
Mod int64 `json:"mod"` // unix nanoseconds
Off int64 `json:"off"` // after the last whole line read
ID string `json:"id,omitempty"`
Cwd string `json:"cwd,omitempty"`
Title string `json:"title,omitempty"`
Named string `json:"named,omitempty"` // the agent's own title for it
First string `json:"first,omitempty"` // the first message, when no prompt looked typed
Start time.Time `json:"start"`
Last time.Time `json:"last"`
Models map[string]Tokens `json:"models,omitempty"`
// Claude Code: the message last counted, whose later lines repeat it
Msg string `json:"msg,omitempty"`
MsgModel string `json:"msg_model,omitempty"`
MsgUse Tokens `json:"msg_use"`
// Codex: the model in use, and its running total (input with cache) last seen
Model string `json:"model,omitempty"`
Total *Tokens `json:"total,omitempty"`
}
func (s *state) use(model string, t Tokens) {
if t.zero() {
return
}
if s.Models == nil {
s.Models = map[string]Tokens{}
}
m := s.Models[model]
m.add(t)
s.Models[model] = m
}
func (s *state) saw(t time.Time) {
if t.IsZero() {
return
}
if s.Start.IsZero() || t.Before(s.Start) {
s.Start = t
}
if t.After(s.Last) {
s.Last = t
}
}
func (s *state) clone() *state {
c := *s
c.Models = make(map[string]Tokens, len(s.Models))
for k, v := range s.Models {
c.Models[k] = v
}
if s.Total != nil {
t := *s.Total
c.Total = &t
}
return &c
}
// file is one session file found on disk.
type file struct {
agent string
key string // agent:session id — a session may span files
path string
main bool // Claude Code: the session's own file, not a subagent's
size int64
mod time.Time
}
// ClaudeDir is Claude Code's folder: $CLAUDE_CONFIG_DIR, else ~/.claude.
func ClaudeDir() string {
if d := os.Getenv("CLAUDE_CONFIG_DIR"); d != "" {
return d
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".claude")
}
// CodexDir is Codex's folder: $CODEX_HOME, else ~/.codex.
func CodexDir() string {
if d := os.Getenv("CODEX_HOME"); d != "" {
return d
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".codex")
}
func stat(f *file) bool {
fi, err := os.Stat(f.path)
if err != nil || !fi.Mode().IsRegular() {
return false
}
f.size, f.mod = fi.Size(), fi.ModTime()
return true
}
func claudeFiles() []file {
projects := filepath.Join(ClaudeDir(), "projects")
var out []file
mains, _ := filepath.Glob(filepath.Join(projects, "*", "*.jsonl"))
for _, p := range mains {
f := file{agent: "claude", key: "claude:" + strings.TrimSuffix(filepath.Base(p), ".jsonl"), path: p, main: true}
if stat(&f) {
out = append(out, f)
}
}
subs, _ := filepath.Glob(filepath.Join(projects, "*", "*", "subagents", "*.jsonl"))
for _, p := range subs {
f := file{agent: "claude", key: "claude:" + filepath.Base(filepath.Dir(filepath.Dir(p))), path: p}
if stat(&f) {
out = append(out, f)
}
}
return out
}
// rollout-2026-09-20T15-48-28-<thread id>[_<segment>].jsonl
var rolloutName = regexp.MustCompile(`^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9A-Za-z-]+?)(?:_[0-9A-Za-z-]+)?\.jsonl$`)
func codexFiles() []file {
var out []file
root := filepath.Join(CodexDir(), "sessions")
filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
m := rolloutName.FindStringSubmatch(d.Name())
if m == nil {
return nil
}
fi, err := d.Info()
if err != nil || !fi.Mode().IsRegular() {
return nil
}
out = append(out, file{agent: "codex", key: "codex:" + m[1], path: p, main: true, size: fi.Size(), mod: fi.ModTime()})
return nil
})
return out
}
var (
mu sync.Mutex
cache map[string]*state // path → parse
loaded bool
)
// CachePath is where the parses are kept between runs.
func CachePath() string { return filepath.Join(filepath.Dir(catalog.CachePath()), "sessions.json") }
// cacheVersion changes when a parse would come out differently, so the
// parses kept by an older magpie are read again.
const cacheVersion = 1
type cacheFile struct {
Version int `json:"version"`
Files map[string]*state `json:"files"`
}
func loadCache() {
if loaded {
return
}
loaded = true
cache = map[string]*state{}
var c cacheFile
if b, err := os.ReadFile(CachePath()); err == nil && json.Unmarshal(b, &c) == nil && c.Version == cacheVersion && c.Files != nil {
cache = c.Files
}
}
func saveCache(keep map[string]bool) {
for p := range cache {
if !keep[p] {
delete(cache, p)
}
}
b, err := json.Marshal(cacheFile{Version: cacheVersion, Files: cache})
if err != nil {
return
}
dir := filepath.Dir(CachePath())
if os.MkdirAll(dir, 0o755) != nil {
return
}
tmp, err := os.CreateTemp(dir, "sessions-*.json")
if err != nil {
return
}
_, err = tmp.Write(b)
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err != nil || os.Rename(tmp.Name(), CachePath()) != nil {
os.Remove(tmp.Name())
}
}
// Reset forgets the kept parses, in memory only.
func Reset() {
mu.Lock()
defer mu.Unlock()
cache, loaded = nil, false
}
// List reads the latest sessions of every agent, the most recently active
// first, at most limit of them (Limit when 0).
func List(limit int) []Session {
if limit <= 0 {
limit = Limit
}
mu.Lock()
defer mu.Unlock()
loadCache()
files := append(claudeFiles(), codexFiles()...)
groups := map[string][]file{}
latest := map[string]time.Time{}
for _, f := range files {
groups[f.key] = append(groups[f.key], f)
if f.mod.After(latest[f.key]) {
latest[f.key] = f.mod
}
}
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if !latest[keys[i]].Equal(latest[keys[j]]) {
return latest[keys[i]].After(latest[keys[j]])
}
return keys[i] < keys[j]
})
if len(keys) > limit {
keys = keys[:limit]
}
// parse what changed, a few files at a time
var todo []file
keep := map[string]bool{}
for _, k := range keys {
for _, f := range groups[k] {
keep[f.path] = true
if s := cache[f.path]; s == nil || s.Size != f.size || s.Mod != f.mod.UnixNano() {
todo = append(todo, f)
}
}
}
if len(todo) > 0 {
var wg sync.WaitGroup
var put sync.Mutex
ch := make(chan file)
for i := 0; i < min(4, runtime.NumCPU(), len(todo)); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for f := range ch {
put.Lock()
old := cache[f.path]
put.Unlock()
s := parse(f, old)
put.Lock()
cache[f.path] = s
put.Unlock()
}
}()
}
for _, f := range todo {
ch <- f
}
close(ch)
wg.Wait()
saveCache(keep)
}
prices := map[string]*catalog.Price{}
price := func(model string) *catalog.Price {
if p, ok := prices[model]; ok {
return p
}
var pp *catalog.Price
if p, ok := PriceOf(model); ok {
pp = &p
}
prices[model] = pp
return pp
}
out := []Session{}
for _, k := range keys {
if s, ok := assemble(groups[k], price); ok {
out = append(out, s)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Last.After(out[j].Last) })
return out
}
// assemble puts a session together from its files' parses.
func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) {
// the session's own file first; for Codex the earliest segment
sort.SliceStable(fs, func(i, j int) bool {
if fs[i].main != fs[j].main {
return fs[i].main
}
return filepath.Base(fs[i].path) < filepath.Base(fs[j].path)
})
s := Session{Agent: fs[0].agent, Path: fs[0].path, Models: []Model{}}
s.ID = strings.TrimPrefix(fs[0].key, s.Agent+":")
var named, first string
models := map[string]*Model{}
for _, f := range fs {
st := cache[f.path]
if st == nil {
continue
}
if f.main {
if s.Cwd == "" {
s.Cwd = st.Cwd
}
if s.Title == "" {
s.Title = st.Title
}
if st.Named != "" {
named = st.Named
}
if first == "" {
first = st.First
}
if s.Agent == "codex" && st.ID != "" && f.path == fs[0].path {
s.ID = st.ID
}
}
if !st.Start.IsZero() && (s.Start.IsZero() || st.Start.Before(s.Start)) {
s.Start = st.Start
}
if st.Last.After(s.Last) {
s.Last = st.Last
}
for name, t := range st.Models {
m := models[name]
if m == nil {
m = &Model{Model: name}
models[name] = m
}
m.add(t)
}
}
if s.Title == "" {
s.Title = named
}
if s.Title == "" {
s.Title = first
}
for _, m := range models {
if p := price(m.Model); p != nil {
m.Cost, m.Priced = p.Cost(m.Input, m.Output, m.CacheRead, m.CacheWrite), true
s.Cost += m.Cost
} else {
s.Unpriced++
}
s.Tokens.add(m.Tokens)
s.Models = append(s.Models, *m)
}
sort.Slice(s.Models, func(i, j int) bool {
a, b := s.Models[i], s.Models[j]
if a.Input+a.Output != b.Input+b.Output {
return a.Input+a.Output > b.Input+b.Output
}
return a.Model < b.Model
})
if s.Last.IsZero() {
s.Last = fs[0].mod
}
if s.Title == "" && s.Tokens.zero() {
return s, false // nothing was said in it
}
s.Resume = ResumeCommand(s.Agent, s.ID, s.Cwd)
return s, true
}
// parse reads a file on from where old left it, or from the start.
func parse(f file, old *state) *state {
var s *state
if old != nil && f.size >= old.Size && old.Off <= f.size {
s = old.clone()
} else {
s = &state{}
}
s.Size, s.Mod = f.size, f.mod.UnixNano()
line := claudeLine
if f.agent == "codex" {
line = codexLine
}
off, err := scan(f.path, s.Off, func(b []byte) { line(s, b, f.main) })
if err == nil {
s.Off = off
}
return s
}
// maxLine is the longest line looked at; a longer one (an image pasted
// inline) is stepped over.
const maxLine = 32 << 20
// scan calls fn on each whole line of the file from off, and returns the
// offset after the last one. A line still being written is left for later.
func scan(path string, off int64, fn func([]byte)) (int64, error) {
f, err := os.Open(path)
if err != nil {
return off, err
}
defer f.Close()
if _, err := f.Seek(off, io.SeekStart); err != nil {
return off, err
}
r := bufio.NewReaderSize(f, 1<<20)
var long []byte
var n int64 // bytes of the line so far
skip := false
for {
chunk, err := r.ReadSlice('\n')
n += int64(len(chunk))
if errors.Is(err, bufio.ErrBufferFull) {
if !skip && len(long)+len(chunk) <= maxLine {
long = append(long, chunk...)
} else {
skip, long = true, long[:0]
}
continue
}
if err != nil {
if errors.Is(err, io.EOF) {
return off, nil
}
return off, err
}
b := chunk
if len(long) > 0 {
long = append(long, chunk...)
b = long
}
if !skip {
if b = bytes.TrimSpace(b); len(b) > 0 {
fn(b)
}
}
off += n
n, skip, long = 0, false, long[:0]
}
}
// tsAt reads the time of a line from its first (or last) "timestamp" key,
// without decoding the rest of it.
func tsAt(b []byte, last bool) time.Time {
key := []byte(`"timestamp":"`)
i := bytes.Index(b, key)
if last {
i = bytes.LastIndex(b, key)
}
if i < 0 {
return time.Time{}
}
rest := b[i+len(key):]
j := bytes.IndexByte(rest, '"')
if j < 0 || j > 40 {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339Nano, string(rest[:j]))
return t
}
var (
space = regexp.MustCompile(`\s+`)
tag = regexp.MustCompile(`</?[A-Za-z][\w-]*[^<>]*>`)
)
// untagged is a message the agent or a harness wrapped in tags, as words.
func untagged(s string) string { return title(tag.ReplaceAllString(s, " ")) }
// title is a prompt made one short line.
func title(s string) string {
s = strings.TrimSpace(space.ReplaceAllString(s, " "))
if r := []rune(s); len(r) > 160 {
s = strings.TrimSpace(string(r[:160])) + "…"
}
return s
}
// ---- pricing ----------------------------------------------------------------
var dated = regexp.MustCompile(`-\d{8}$`)
// priceOf prices a model as a session names it: one through magpie as
// "<provider>/<model>" at the price the gateway counts it at, else the bare
// id at its maker's list price on models.dev.
func priceOf(model string) (catalog.Price, bool) {
m := strings.TrimSpace(model)
if m == "" {
return catalog.Price{}, false
}
if pid, rest, ok := strings.Cut(m, "/"); ok {
for _, p := range provider.All() {
if p.ID == pid {
for _, c := range p.Catalogs() {
if pr, ok := catalog.PriceOf(c, rest); ok {
return pr, true
}
}
break
}
}
}
bare := strings.ToLower(m[strings.LastIndexByte(m, '/')+1:])
for _, id := range []string{bare, dated.ReplaceAllString(bare, "")} {
for _, c := range makers(id) {
if pr, ok := catalog.PriceOf(c, id); ok {
return pr, true
}
}
}
return catalog.Price{}, false
}
// makers are the models.dev providers that make a model of this id.
func makers(id string) []string {
for _, m := range []struct{ prefix, provider string }{
{"claude", "anthropic"}, {"gpt", "openai"}, {"o1", "openai"}, {"o3", "openai"}, {"o4", "openai"},
{"codex", "openai"}, {"gemini", "google"}, {"deepseek", "deepseek"}, {"grok", "xai"},
{"glm", "zai"}, {"kimi", "moonshotai"}, {"qwen", "alibaba"}, {"mistral", "mistral"},
{"devstral", "mistral"}, {"minimax", "minimax"},
} {
if strings.HasPrefix(id, m.prefix) {
return []string{m.provider}
}
}
return nil
}
// ---- resuming ---------------------------------------------------------------
var safeID = regexp.MustCompile(`^[0-9A-Za-z_-]+$`)
// ResumeCommand is the shell line that picks a session up again in its
// folder, or "" for an id that isn't plain.
func ResumeCommand(agent, id, cwd string) string {
if !safeID.MatchString(id) {
return ""
}
var run string
switch agent {
case "claude":
run = "claude --resume " + id
case "codex":
run = "codex resume " + id
default:
return ""
}
if cwd == "" {
return run
}
if runtime.GOOS == "windows" {
return "Set-Location -LiteralPath '" + strings.ReplaceAll(cwd, "'", "''") + "'; " + run
}
return "cd " + shellQuote(cwd) + " && " + run
}
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
// Find is a listed session by agent and id.
func Find(agent, id string) (Session, bool) {
for _, s := range List(0) {
if s.Agent == agent && s.ID == id {
return s, true
}
}
return Session{}, false
}
+225
View File
@@ -0,0 +1,225 @@
package sessions
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// copyTree copies the fixtures somewhere the test may change them.
func copyTree(t *testing.T, from, to string) {
t.Helper()
err := filepath.WalkDir(from, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, p)
if d.IsDir() {
return os.MkdirAll(filepath.Join(to, rel), 0o755)
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
})
if err != nil {
t.Fatal(err)
}
}
func setup(t *testing.T) (claude, codex string) {
dir := t.TempDir()
claude, codex = filepath.Join(dir, "claude"), filepath.Join(dir, "codex")
copyTree(t, "testdata/claude", claude)
copyTree(t, "testdata/codex", codex)
t.Setenv("CLAUDE_CONFIG_DIR", claude)
t.Setenv("CODEX_HOME", codex)
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "cache"))
PriceOf = func(m string) (catalog.Price, bool) {
switch m {
case "claude-opus-5-5":
return catalog.Price{Input: 4, Output: 20, CacheRead: 0.2, CacheWrite: 5}, true
case "gpt-6-astra":
return catalog.Price{Input: 10, Output: 50, CacheRead: 1}, true
}
return catalog.Price{}, false
}
t.Cleanup(func() { PriceOf = priceOf; Reset() })
Reset()
return
}
func model(s Session, name string) Model {
for _, m := range s.Models {
if m.Model == name {
return m
}
}
return Model{}
}
func near(a, b float64) bool { return a-b < 1e-9 && b-a < 1e-9 }
func TestList(t *testing.T) {
setup(t)
ss := List(0)
if len(ss) != 2 {
t.Fatalf("want 2 sessions (the empty one left out), got %d: %+v", len(ss), ss)
}
cx, cc := ss[0], ss[1]
if cx.Agent != "codex" || cc.Agent != "claude" {
t.Fatalf("order by last activity: %s, %s", cx.Agent, cc.Agent)
}
// Claude Code: a message's blocks counted once, the subagent's calls in
if cc.ID != "11111111-2222-3333-4444-555555555555" || cc.Cwd != "/work/app" || cc.Title != "Fix the login bug in auth.go" {
t.Fatalf("claude: %+v", cc)
}
if o := model(cc, "claude-opus-5-5"); o.Tokens != (Tokens{100, 50, 5000, 1000}) || !o.Priced {
t.Fatalf("opus: %+v", o)
}
if h := model(cc, "claude-haiku-4-5-20251001"); h.Tokens != (Tokens{1010, 120, 200, 0}) || h.Priced {
t.Fatalf("haiku: %+v", h)
}
if want := (100*4 + 50*20 + 5000*0.2 + 1000*5) / 1e6; !near(cc.Cost, want) || cc.Unpriced != 1 {
t.Fatalf("claude cost %v unpriced %d, want %v 1", cc.Cost, cc.Unpriced, want)
}
if !cc.Start.Equal(time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)) || !cc.Last.Equal(time.Date(2026, 9, 20, 10, 5, 0, 0, time.UTC)) {
t.Fatalf("claude times %s %s", cc.Start, cc.Last)
}
// Codex: two files of one thread, the repeated count adding nothing,
// the cache taken out of the input
if cx.ID != "01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd" || cx.Cwd != "/work/it's" || cx.Title != "Add a README" {
t.Fatalf("codex: %+v", cx)
}
if a := model(cx, "gpt-6-astra"); a.Tokens != (Tokens{8000, 400, 10000, 0}) {
t.Fatalf("astra: %+v", a)
}
if l := model(cx, "gpt-6-luna"); l.Tokens != (Tokens{2000, 200, 10000, 0}) {
t.Fatalf("luna: %+v", l)
}
if cx.Input != 10000 || cx.Output != 600 || cx.CacheRead != 20000 || cx.Unpriced != 1 {
t.Fatalf("codex totals: %+v", cx)
}
if !cx.Last.Equal(time.Date(2026, 9, 23, 0, 27, 20, 0, time.UTC)) {
t.Fatalf("codex last %s", cx.Last)
}
if runtime.GOOS != "windows" {
if want := `cd '/work/it'\''s' && codex resume 01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd`; cx.Resume != want {
t.Fatalf("resume %q", cx.Resume)
}
if want := "cd '/work/app' && claude --resume 11111111-2222-3333-4444-555555555555"; cc.Resume != want {
t.Fatalf("resume %q", cc.Resume)
}
}
if got := List(1); len(got) != 1 {
t.Fatalf("limit: %d", len(got))
}
}
func TestIncremental(t *testing.T) {
claude, _ := setup(t)
path := filepath.Join(claude, "projects", "-work-app", "11111111-2222-3333-4444-555555555555.jsonl")
before := model(List(0)[1], "claude-opus-5-5")
line := `{"parentUuid":"a3","isSidechain":false,"message":{"model":"claude-opus-5-5","id":"msg_3","type":"message","role":"assistant","content":[],"usage":{"input_tokens":7,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":3}},"type":"assistant","uuid":"a4","timestamp":"2026-09-20T11:00:00.000Z","sessionId":"11111111-2222-3333-4444-555555555555"}`
appendTo := func(s string) {
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
f.WriteString(s)
f.Close()
}
// half a line, still being written: left for later
appendTo(line[:40])
if got := model(List(0)[1], "claude-opus-5-5"); got.Tokens != before.Tokens {
t.Fatalf("a partial line was counted: %+v", got)
}
off := cache[path].Off
appendTo(line[40:] + "\n")
s := List(0)[1]
if got := model(s, "claude-opus-5-5"); got.Input != before.Input+7 || got.Output != before.Output+3 {
t.Fatalf("appended line: %+v", got)
}
if !s.Last.Equal(time.Date(2026, 9, 20, 11, 0, 0, 0, time.UTC)) {
t.Fatalf("last %s", s.Last)
}
if cache[path].Off <= off {
t.Fatalf("offset did not move on: %d → %d", off, cache[path].Off)
}
// kept on disk: a new process reads the parse back, not the file (here
// changed in place, same size and time, so a re-read would show it)
b, err := os.ReadFile(CachePath())
if err != nil {
t.Fatal(err)
}
var kept cacheFile
if json.Unmarshal(b, &kept) != nil || kept.Version != cacheVersion || kept.Files[path] == nil {
t.Fatalf("cache file: %s", b)
}
fi, _ := os.Stat(path)
raw, _ := os.ReadFile(path)
os.WriteFile(path, []byte(strings.Replace(string(raw), `"input_tokens":7,`, `"input_tokens":8,`, 1)), 0o644)
os.Chtimes(path, fi.ModTime(), fi.ModTime())
Reset()
if got := model(List(0)[1], "claude-opus-5-5"); got.Input != before.Input+7 {
t.Fatalf("parse not kept: %+v", got)
}
// a file that shrank is read again from the start
os.WriteFile(path, raw[:len(raw)-len(line)-1], 0o644)
if got := model(List(0)[1], "claude-opus-5-5"); got.Tokens != before.Tokens {
t.Fatalf("after shrinking: %+v", got)
}
}
func TestPrompt(t *testing.T) {
for in, want := range map[string]string{
`"<command-message>review</command-message>\n<command-name>/review</command-name>\n<command-args>42</command-args>"`: "/review 42",
`[{"type":"text","text":"look at this"},{"type":"image","source":{}}]`: "look at this",
`[{"type":"tool_result","tool_use_id":"x","content":"ok"}]`: "",
`"<local-command-stdout>hi</local-command-stdout>"`: "",
`"[Request interrupted by user]"`: "",
} {
if got := ccPrompt(json.RawMessage(in)); got != want {
t.Errorf("ccPrompt(%s) = %q, want %q", in, got, want)
}
}
long := strings.Repeat("字", 300)
if got := []rune(title(long)); len(got) != 161 || got[160] != '…' {
t.Errorf("title not cut: %d", len(got))
}
}
func TestResumeCommand(t *testing.T) {
if ResumeCommand("claude", "x; rm -rf /", "/tmp") != "" {
t.Fatal("an id that isn't plain must give no command")
}
if got := ResumeCommand("codex", "abc", ""); got != "codex resume abc" {
t.Fatalf("no folder: %q", got)
}
if ResumeCommand("pi", "abc", "/tmp") != "" {
t.Fatal("unknown agent")
}
}
func TestRolloutName(t *testing.T) {
for name, id := range map[string]string{
"rollout-2026-09-20T15-48-28-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd.jsonl": "01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd",
"rollout-2026-09-23T00-27-05-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd_01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl": "01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd",
} {
if m := rolloutName.FindStringSubmatch(name); m == nil || m[1] != id {
t.Errorf("%s: %v", name, m)
}
}
}
@@ -0,0 +1,8 @@
{"type":"permission-mode","permissionMode":"default","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":"<local-command-caveat>Caveat: the messages below were generated by the user while running local commands.</local-command-caveat>"},"isMeta":true,"uuid":"u0","timestamp":"2026-09-20T10:00:00.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":"u0","isSidechain":false,"type":"user","message":{"role":"user","content":"Fix the login\nbug in auth.go"},"uuid":"u1","timestamp":"2026-09-20T10:00:01.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-opus-5-5","id":"msg_1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"..."}],"usage":{"input_tokens":100,"cache_creation_input_tokens":1000,"cache_read_input_tokens":5000,"output_tokens":50}},"requestId":"req_1","type":"assistant","uuid":"a1","timestamp":"2026-09-20T10:00:05.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":"a1","isSidechain":false,"message":{"model":"claude-opus-5-5","id":"msg_1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{}}],"usage":{"input_tokens":100,"cache_creation_input_tokens":1000,"cache_read_input_tokens":5000,"output_tokens":50}},"requestId":"req_1","type":"assistant","uuid":"a2","timestamp":"2026-09-20T10:00:06.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":"a2","isSidechain":false,"type":"user","message":{"role":"user","content":[{"tool_use_id":"t1","type":"tool_result","content":"{\"type\":\"assistant\",\"timestamp\":\"2030-01-01T00:00:00Z\"}"}]},"uuid":"u2","timestamp":"2026-09-20T10:00:07.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
{"type":"ai-title","aiTitle":"Fix login bug","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":"u2","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_2","type":"message","role":"assistant","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":200,"output_tokens":20}},"requestId":"req_2","type":"assistant","uuid":"a3","timestamp":"2026-09-20T10:05:00.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
@@ -0,0 +1,2 @@
{"parentUuid":null,"isSidechain":true,"type":"user","message":{"role":"user","content":"Search the repo"},"uuid":"s1","timestamp":"2026-09-20T10:01:00.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
{"parentUuid":"s1","isSidechain":true,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_s1","type":"message","role":"assistant","content":[{"type":"text","text":"found"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":100}},"type":"assistant","uuid":"s2","timestamp":"2026-09-20T10:02:00.000Z","cwd":"/work/app","sessionId":"11111111-2222-3333-4444-555555555555"}
@@ -0,0 +1 @@
{"type":"file-history-snapshot","messageId":"m","snapshot":{"messageId":"m","trackedFileBackups":{},"timestamp":"2026-09-19T08:00:00.000Z"},"isSnapshotUpdate":false}
@@ -0,0 +1,10 @@
{"timestamp":"2026-09-20T15:48:28.100Z","type":"session_meta","payload":{"id":"01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd","timestamp":"2026-09-20T15:48:28.000Z","cwd":"/work/it's","originator":"codex_cli_rs","cli_version":"0.99.0","source":"cli","model_provider":"openai","base_instructions":{"text":"You are Codex"}}}
{"timestamp":"2026-09-20T15:48:29.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/work/it's</cwd>\n</environment_context>"}]}}
{"timestamp":"2026-09-20T15:48:29.100Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Add a README"}]}}
{"timestamp":"2026-09-20T15:48:29.200Z","type":"event_msg","payload":{"type":"user_message","message":"Add a README","images":[]}}
{"timestamp":"2026-09-20T15:48:29.300Z","type":"turn_context","payload":{"cwd":"/work/it's","approval_policy":"on-request","model":"gpt-6-astra","summary":"auto"}}
{"timestamp":"2026-09-20T15:48:35.000Z","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{}}}
{"timestamp":"2026-09-20T15:48:36.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10000,"cached_input_tokens":4000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":10300},"last_token_usage":{"input_tokens":10000,"cached_input_tokens":4000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":10300},"model_context_window":258400}}}
{"timestamp":"2026-09-20T15:48:36.100Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10000,"cached_input_tokens":4000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":10300},"last_token_usage":{"input_tokens":10000,"cached_input_tokens":4000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":10300},"model_context_window":258400}}}
{"timestamp":"2026-09-20T15:50:00.000Z","type":"turn_context","payload":{"cwd":"/work/it's","model":"gpt-6-luna"}}
{"timestamp":"2026-09-20T15:50:10.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":22000,"cached_input_tokens":14000,"output_tokens":500,"reasoning_output_tokens":150,"total_tokens":22500},"last_token_usage":{"input_tokens":12000,"cached_input_tokens":10000,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":12200},"model_context_window":258400}}}
@@ -0,0 +1,4 @@
{"timestamp":"2026-09-23T00:27:05.000Z","type":"session_meta","payload":{"id":"01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd","cwd":"/work/it's","originator":"codex_cli_rs","source":{"subagent":"review"}}}
{"timestamp":"2026-09-23T00:27:06.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"and a LICENSE"}]}}
{"timestamp":"2026-09-23T00:27:07.000Z","type":"turn_context","payload":{"cwd":"/work/it's","model":"gpt-6-astra"}}
{"timestamp":"2026-09-23T00:27:20.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":30000,"cached_input_tokens":20000,"output_tokens":600,"total_tokens":30600},"last_token_usage":{"input_tokens":8000,"cached_input_tokens":6000,"output_tokens":100,"total_tokens":8100},"model_context_window":258400}}}