From 78fe45dbc38edb42b503201338224b01fddae2b6 Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 27 Sep 2026 17:47:14 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20Sessions=20=E2=80=94=20Claude=20Code's?= =?UTF-8?q?=20and=20Codex's=20own=20session=20files=20read=20into=20a=20li?= =?UTF-8?q?st=20beside=20the=20overview:=20each=20session's=20folder,=20fi?= =?UTF-8?q?rst=20prompt,=20models,=20tokens=20and=20estimated=20cost=20at?= =?UTF-8?q?=20list=20price,=20filtered=20by=20agent=20and=20searched;=20Re?= =?UTF-8?q?sume=20copies=20the=20cd=20and=20claude=20--resume=20/=20codex?= =?UTF-8?q?=20resume=20that=20picks=20it=20up,=20and=20the=20Mac=20app=20c?= =?UTF-8?q?an=20open=20it=20in=20Terminal.=20Only=20the=20latest=20200=20a?= =?UTF-8?q?re=20read,=20cached=20by=20size=20and=20time,=20nothing=20writt?= =?UTF-8?q?en=20to=20the=20agents'=20folders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/api.go | 1 + internal/gui/assets/app.css | 41 ++ internal/gui/assets/app.js | 254 ++++++- internal/gui/assets/i18n.js | 27 + internal/gui/assets/index.html | 39 +- internal/gui/sessions.go | 103 +++ internal/sessions/claude.go | 145 ++++ internal/sessions/codex.go | 146 ++++ internal/sessions/sessions.go | 660 ++++++++++++++++++ internal/sessions/sessions_test.go | 225 ++++++ ...11111111-2222-3333-4444-555555555555.jsonl | 8 + .../subagents/agent-a1.jsonl | 2 + ...22222222-2222-3333-4444-555555555555.jsonl | 1 + ...01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd.jsonl | 10 + ...01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl | 4 + 15 files changed, 1651 insertions(+), 15 deletions(-) create mode 100644 internal/gui/sessions.go create mode 100644 internal/sessions/claude.go create mode 100644 internal/sessions/codex.go create mode 100644 internal/sessions/sessions.go create mode 100644 internal/sessions/sessions_test.go create mode 100644 internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555.jsonl create mode 100644 internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555/subagents/agent-a1.jsonl create mode 100644 internal/sessions/testdata/claude/projects/-work-app/22222222-2222-3333-4444-555555555555.jsonl create mode 100644 internal/sessions/testdata/codex/sessions/2026/09/20/rollout-2026-09-20T15-48-28-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd.jsonl create mode 100644 internal/sessions/testdata/codex/sessions/2026/09/23/rollout-2026-09-23T00-27-05-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd_01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl diff --git a/internal/gui/api.go b/internal/gui/api.go index fcdf6be7..505c4d6b 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -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) diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index cf956065..7f86cb81 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1dd98f19..fb3c3a68 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index ee0ca46f..dc2914e9 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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 · 本月", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 2c5b0890..1629dc85 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -83,24 +83,37 @@
+
-
diff --git a/internal/gui/sessions.go b/internal/gui/sessions.go new file mode 100644 index 00000000..d7ccfe52 --- /dev/null +++ b/internal/gui/sessions.go @@ -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 +} diff --git a/internal/sessions/claude.go b/internal/sessions/claude.go new file mode 100644 index 00000000..1be46b52 --- /dev/null +++ b/internal/sessions/claude.go @@ -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 == "" { + 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(`\s*([^<]*?)\s*`) + ccArgs = regexp.MustCompile(`\s*([^<]*?)\s*`) +) + +// 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) +} diff --git a/internal/sessions/codex.go b/internal/sessions/codex.go new file mode 100644 index 00000000..358e31f3 --- /dev/null +++ b/internal/sessions/codex.go @@ -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) +} diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go new file mode 100644 index 00000000..b91ad4a7 --- /dev/null +++ b/internal/sessions/sessions.go @@ -0,0 +1,660 @@ +// Package sessions lists the agents' recent sessions from their own session +// files — Claude Code's projects/*/.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-[_].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(`]*>`) +) + +// 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 +// "/" 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 +} diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go new file mode 100644 index 00000000..3ebf9fca --- /dev/null +++ b/internal/sessions/sessions_test.go @@ -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{ + `"review\n/review\n42"`: "/review 42", + `[{"type":"text","text":"look at this"},{"type":"image","source":{}}]`: "look at this", + `[{"type":"tool_result","tool_use_id":"x","content":"ok"}]`: "", + `"hi"`: "", + `"[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) + } + } +} diff --git a/internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555.jsonl b/internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555.jsonl new file mode 100644 index 00000000..5b09a951 --- /dev/null +++ b/internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555.jsonl @@ -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":"Caveat: the messages below were generated by the user while running local commands."},"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"} diff --git a/internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555/subagents/agent-a1.jsonl b/internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555/subagents/agent-a1.jsonl new file mode 100644 index 00000000..be355b4c --- /dev/null +++ b/internal/sessions/testdata/claude/projects/-work-app/11111111-2222-3333-4444-555555555555/subagents/agent-a1.jsonl @@ -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"} diff --git a/internal/sessions/testdata/claude/projects/-work-app/22222222-2222-3333-4444-555555555555.jsonl b/internal/sessions/testdata/claude/projects/-work-app/22222222-2222-3333-4444-555555555555.jsonl new file mode 100644 index 00000000..2cfcd9bc --- /dev/null +++ b/internal/sessions/testdata/claude/projects/-work-app/22222222-2222-3333-4444-555555555555.jsonl @@ -0,0 +1 @@ +{"type":"file-history-snapshot","messageId":"m","snapshot":{"messageId":"m","trackedFileBackups":{},"timestamp":"2026-09-19T08:00:00.000Z"},"isSnapshotUpdate":false} diff --git a/internal/sessions/testdata/codex/sessions/2026/09/20/rollout-2026-09-20T15-48-28-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd.jsonl b/internal/sessions/testdata/codex/sessions/2026/09/20/rollout-2026-09-20T15-48-28-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd.jsonl new file mode 100644 index 00000000..5290d2b8 --- /dev/null +++ b/internal/sessions/testdata/codex/sessions/2026/09/20/rollout-2026-09-20T15-48-28-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd.jsonl @@ -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":"\n /work/it's\n"}]}} +{"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}}} diff --git a/internal/sessions/testdata/codex/sessions/2026/09/23/rollout-2026-09-23T00-27-05-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd_01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl b/internal/sessions/testdata/codex/sessions/2026/09/23/rollout-2026-09-23T00-27-05-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd_01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl new file mode 100644 index 00000000..cc02e70b --- /dev/null +++ b/internal/sessions/testdata/codex/sessions/2026/09/23/rollout-2026-09-23T00-27-05-01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd_01a0c9f1-3d6f-7f33-b3bd-25c1dd14251b.jsonl @@ -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}}}