Compare commits

...

8 Commits

Author SHA1 Message Date
Claude
50ac9c11c6 chore(telegram): bump version to 0.0.5 2026-04-10 18:57:08 +00:00
Claude
face11b559 fix(telegram): prevent zombie pollers from blocking new sessions
The MCP server runs as a grandchild of the CLI (via `bun run start` →
shell → `bun server.ts`). When the CLI is killed uncleanly (SIGKILL,
crash, terminal close), the grandchild survives as an orphan and keeps
long-polling getUpdates indefinitely. Telegram allows only one consumer
per token, so every subsequent session sees 409 Conflict and the
existing retry loop spins forever.

Three layered mitigations:

- PID lockfile (STATE_DIR/bot.pid): on startup, SIGTERM any stale holder
  before claiming the slot, so a fresh session always wins.
- Orphan watchdog: every 5s check for parent reparenting (POSIX ppid
  change) or a dead stdin pipe, and self-terminate. Covers cases where
  the existing stdin end/close events never fire through the wrapper.
- 409 retry cap: give up after 8 attempts (~28s) instead of looping
  forever, and bail immediately if shutdown has begun.

Also adds a SIGHUP handler and removes the pidfile on clean shutdown
(only if still owned by this process).
2026-04-10 18:51:50 +00:00
Thariq Shihipar
1057d02c53 Merge pull request #1333 from anthropics/thariq/session-report-timeline
session-report: add per-day timeline and collapse cache-breaks
2026-04-09 21:45:42 -07:00
Thariq Shihipar
9dc3809e74 Add per-day session timeline and collapse cache-breaks list
- analyze-sessions.mjs: track per-session start/end/tokens and emit
  by_day[] in JSON output (date, dow, tokens, peak concurrency,
  per-session spans). Hoist shared token-sum in commit loop.
- template.html: new "session timeline by day" section — horizontal
  day pills (% of total + session count) drive a lane-packed gantt of
  concurrent sessions, colored by project, with hover details and
  ←/→ keyboard nav. Extract drillList() helper and use it for both
  top-prompts and cache-breaks (5 visible + "show more" toggle).
2026-04-09 21:32:34 -07:00
Bryan Thompson
6e43e87fc8 Add box plugin (box/box-for-ai) — first-party skills plugin for Box Platform integrations. (#1286)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:11:31 +01:00
Bryan Thompson
b32879bf76 Add SAP CAP MCP Server plugin (cds-mcp) (#1328)
URL-source plugin pointing to cap-js/mcp-server which already has
.claude-plugin/plugin.json and .mcp.json at repo root.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:30:37 +01:00
Tobin South
98c01d3fbf Add zoom-plugin to official marketplace (#1313)
Promotes zoom-plugin from community-internal (PR #1567, merged
2026-04-07) to official. Uses url source pointing at
github.com/zoom/zoom-plugin without a SHA pin to track upstream.

Ref: DIR-77, DIR-79
2026-04-08 23:42:03 +01:00
Tobin South
ce0166dde2 Add expo plugin to official marketplace (#1312)
Promotes expo from community to official. Uses git-subdir source
(expo/skills @ plugins/expo, ref main) without a SHA pin so it
tracks upstream. Description cleaned to remove the reviewer-note
preamble that leaked into the community entry.

Ref: DIR-77, DIR-79
2026-04-08 14:31:55 -07:00
5 changed files with 300 additions and 52 deletions

View File

@@ -147,6 +147,17 @@
}, },
"homepage": "https://github.com/awslabs/agent-plugins" "homepage": "https://github.com/awslabs/agent-plugins"
}, },
{
"name": "box",
"description": "Work with your Box content directly from Claude Code — search files, organize folders, collaborate with your team, and use Box AI to answer questions, summarize documents, and extract data without leaving your workflow.",
"category": "productivity",
"source": {
"source": "url",
"url": "https://github.com/box/box-for-ai.git",
"sha": "6f4ec3549f3e869b115628403555b1c9220b2b34"
},
"homepage": "https://github.com/box/box-for-ai"
},
{ {
"name": "brightdata-plugin", "name": "brightdata-plugin",
"description": "Web scraping, Google search, structured data extraction, and MCP server integration powered by Bright Data. Includes 7 skills: scrape any webpage as markdown (with bot detection/CAPTCHA bypass), search Google with structured JSON results, extract data from 40+ websites (Amazon, LinkedIn, Instagram, TikTok, YouTube, and more), orchestrate Bright Data's 60+ MCP tools, built-in best practices for Web Unlocker, SERP API, Web Scraper API, and Browser API, Python SDK best practices for the brightda...", "description": "Web scraping, Google search, structured data extraction, and MCP server integration powered by Bright Data. Includes 7 skills: scrape any webpage as markdown (with bot detection/CAPTCHA bypass), search Google with structured JSON results, extract data from 40+ websites (Amazon, LinkedIn, Instagram, TikTok, YouTube, and more), orchestrate Bright Data's 60+ MCP tools, built-in best practices for Web Unlocker, SERP API, Web Scraper API, and Browser API, Python SDK best practices for the brightda...",
@@ -157,6 +168,17 @@
}, },
"homepage": "https://docs.brightdata.com" "homepage": "https://docs.brightdata.com"
}, },
{
"name": "cds-mcp",
"description": "AI-assisted development of SAP Cloud Application Programming Model (CAP) projects. Search CDS models and CAP documentation.",
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/cap-js/mcp-server.git",
"sha": "4d59d7070a52761a9b8028cbe710c8d7477cbc92"
},
"homepage": "https://cap.cloud.sap/"
},
{ {
"name": "chrome-devtools-mcp", "name": "chrome-devtools-mcp",
"description": "Control and inspect a live Chrome browser from your coding agent. Record performance traces, analyze network requests, check console messages with source-mapped stack traces, and automate browser actions with Puppeteer.", "description": "Control and inspect a live Chrome browser from your coding agent. Record performance traces, analyze network requests, check console messages with source-mapped stack traces, and automate browser actions with Puppeteer.",
@@ -384,6 +406,18 @@
"category": "learning", "category": "learning",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/explanatory-output-style" "homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/explanatory-output-style"
}, },
{
"name": "expo",
"description": "Official Expo skills for building, deploying, upgrading, and debugging React Native apps with Expo. Covers UI development with Expo Router, SwiftUI and Jetpack Compose components, Tailwind CSS setup, API routes, data fetching, CI/CD workflows, App Store and Play Store deployment, SDK upgrades, DOM components, and dev client distribution.",
"category": "development",
"source": {
"source": "git-subdir",
"url": "expo/skills",
"path": "plugins/expo",
"ref": "main"
},
"homepage": "https://github.com/expo/skills/blob/main/plugins/expo/README.md"
},
{ {
"name": "fakechat", "name": "fakechat",
"description": "Localhost web chat for testing the channel notification flow. No tokens, no access control, no third-party service.", "description": "Localhost web chat for testing the channel notification flow. No tokens, no access control, no third-party service.",
@@ -1453,6 +1487,16 @@
"sha": "b93007e9a726c6ee93c57a949e732744ef5acbfd" "sha": "b93007e9a726c6ee93c57a949e732744ef5acbfd"
}, },
"homepage": "https://github.com/zapier/zapier-mcp/tree/main/plugins/zapier" "homepage": "https://github.com/zapier/zapier-mcp/tree/main/plugins/zapier"
},
{
"name": "zoom-plugin",
"description": "Claude plugin for planning, building, and debugging Zoom integrations across REST APIs, SDKs, webhooks, bots, and MCP workflows.",
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/zoom/zoom-plugin.git"
},
"homepage": "https://developers.zoom.us/"
} }
] ]
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "telegram", "name": "telegram",
"description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.",
"version": "0.0.4", "version": "0.0.5",
"keywords": [ "keywords": [
"telegram", "telegram",
"messaging", "messaging",

View File

@@ -51,6 +51,22 @@ if (!TOKEN) {
process.exit(1) process.exit(1)
} }
const INBOX_DIR = join(STATE_DIR, 'inbox') const INBOX_DIR = join(STATE_DIR, 'inbox')
const PID_FILE = join(STATE_DIR, 'bot.pid')
// Telegram allows exactly one getUpdates consumer per token. If a previous
// session crashed (SIGKILL, terminal closed) its server.ts grandchild can
// survive as an orphan and hold the slot forever, so every new session sees
// 409 Conflict. Kill any stale holder before we start polling.
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
try {
const stale = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
if (stale > 1 && stale !== process.pid) {
process.kill(stale, 0)
process.stderr.write(`telegram channel: replacing stale poller pid=${stale}\n`)
process.kill(stale, 'SIGTERM')
}
} catch {}
writeFileSync(PID_FILE, String(process.pid))
// Last-resort safety net — without these the process dies silently on any // Last-resort safety net — without these the process dies silently on any
// unhandled promise rejection. With them it logs and keeps serving tools. // unhandled promise rejection. With them it logs and keeps serving tools.
@@ -621,6 +637,9 @@ function shutdown(): void {
if (shuttingDown) return if (shuttingDown) return
shuttingDown = true shuttingDown = true
process.stderr.write('telegram channel: shutting down\n') process.stderr.write('telegram channel: shutting down\n')
try {
if (parseInt(readFileSync(PID_FILE, 'utf8'), 10) === process.pid) rmSync(PID_FILE)
} catch {}
// bot.stop() signals the poll loop to end; the current getUpdates request // bot.stop() signals the poll loop to end; the current getUpdates request
// may take up to its long-poll timeout to return. Force-exit after 2s. // may take up to its long-poll timeout to return. Force-exit after 2s.
setTimeout(() => process.exit(0), 2000) setTimeout(() => process.exit(0), 2000)
@@ -630,6 +649,19 @@ process.stdin.on('end', shutdown)
process.stdin.on('close', shutdown) process.stdin.on('close', shutdown)
process.on('SIGTERM', shutdown) process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown) process.on('SIGINT', shutdown)
process.on('SIGHUP', shutdown)
// Orphan watchdog: stdin events above don't reliably fire when the parent
// chain (`bun run` wrapper → shell → us) is severed by a crash. Poll for
// reparenting (POSIX) or a dead stdin pipe and self-terminate.
const bootPpid = process.ppid
setInterval(() => {
const orphaned =
(process.platform !== 'win32' && process.ppid !== bootPpid) ||
process.stdin.destroyed ||
process.stdin.readableEnded
if (orphaned) shutdown()
}, 5000).unref()
// Commands are DM-only. Responding in groups would: (1) leak pairing codes via // Commands are DM-only. Responding in groups would: (1) leak pairing codes via
// /status to other group members, (2) confirm bot presence in non-allowlisted // /status to other group members, (2) confirm bot presence in non-allowlisted
@@ -975,7 +1007,15 @@ void (async () => {
}) })
return // bot.stop() was called — clean exit from the loop return // bot.stop() was called — clean exit from the loop
} catch (err) { } catch (err) {
if (shuttingDown) return
if (err instanceof GrammyError && err.error_code === 409) { if (err instanceof GrammyError && err.error_code === 409) {
if (attempt >= 8) {
process.stderr.write(
`telegram channel: 409 Conflict persists after ${attempt} attempts — ` +
`another poller is holding the bot token (stray 'bun server.ts' process or a second session). Exiting.\n`,
)
return
}
const delay = Math.min(1000 * attempt, 15000) const delay = Math.min(1000 * attempt, 15000)
const detail = attempt === 1 const detail = attempt === 1
? ' — another instance is polling (zombie session, or a second Claude Code running?)' ? ' — another instance is polling (zombie session, or a second Claude Code running?)'

View File

@@ -166,6 +166,7 @@ const toolUseIdToPrompt = new Map() // tool_use id -> promptKey (Agent spawned d
const agentIdToPrompt = new Map() // agentId -> promptKey const agentIdToPrompt = new Map() // agentId -> promptKey
const prompts = new Map() // promptKey -> { text, ts, project, sessionId, ...usage } const prompts = new Map() // promptKey -> { text, ts, project, sessionId, ...usage }
const sessionTurns = new Map() // sessionId -> [promptKey, ...] in transcript order const sessionTurns = new Map() // sessionId -> [promptKey, ...] in transcript order
const sessionSpans = new Map() // sessionId -> {project, firstTs, lastTs, tokens}
function promptRecord(key, init) { function promptRecord(key, init) {
let r = prompts.get(key) let r = prompts.get(key)
@@ -333,11 +334,29 @@ async function processFile(p, info, buckets) {
} }
} }
// session span (for by_day timeline) — subagent files roll into parent sessionId
let span = sessionSpans.get(info.sessionId)
if (!span) {
span = { project: info.project, firstTs: null, lastTs: null, tokens: 0 }
sessionSpans.set(info.sessionId, span)
}
if (firstTs !== null) {
if (span.firstTs === null || firstTs < span.firstTs) span.firstTs = firstTs
if (span.lastTs === null || lastTs > span.lastTs) span.lastTs = lastTs
}
// commit API calls // commit API calls
for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) { for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) {
if (key && seenRequestIds.has(key)) continue if (key && seenRequestIds.has(key)) continue
seenRequestIds.add(key) seenRequestIds.add(key)
const tot =
(usage.input_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0) +
(usage.output_tokens || 0)
span.tokens += tot
const targets = [overall, project] const targets = [overall, project]
if (subagent) targets.push(subagent) if (subagent) targets.push(subagent)
if (skill && skillStats) { if (skill && skillStats) {
@@ -359,11 +378,6 @@ async function processFile(p, info, buckets) {
// subagent token accounting on parent buckets // subagent token accounting on parent buckets
if (info.kind === 'subagent') { if (info.kind === 'subagent') {
const tot =
(usage.input_tokens || 0) +
(usage.cache_creation_input_tokens || 0) +
(usage.cache_read_input_tokens || 0) +
(usage.output_tokens || 0)
overall.subagentTokens += tot overall.subagentTokens += tot
project.subagentTokens += tot project.subagentTokens += tot
if (subagent) subagent.subagentTokens += tot if (subagent) subagent.subagentTokens += tot
@@ -656,10 +670,55 @@ function printJson({ overall, perProject, perSubagent, perSkill }) {
[...perSkill].map(([k, v]) => [k, summarize(v)]), [...perSkill].map(([k, v]) => [k, summarize(v)]),
), ),
top_prompts: topPrompts(100), top_prompts: topPrompts(100),
by_day: buildByDay(),
} }
process.stdout.write(JSON.stringify(out, null, 2) + '\n') process.stdout.write(JSON.stringify(out, null, 2) + '\n')
} }
// Group sessions into local-date buckets for the timeline view. A session is
// placed on the day its first message landed; tokens for that session (incl.
// subagents) count toward that day even if it ran past midnight.
function buildByDay() {
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const days = new Map() // yyyy-mm-dd -> {date, dow, tokens, sessions:[]}
for (const [id, s] of sessionSpans) {
if (s.firstTs === null || s.tokens === 0) continue
const d0 = new Date(s.firstTs)
const key = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-${String(d0.getDate()).padStart(2, '0')}`
let day = days.get(key)
if (!day) {
day = { date: key, dow: DOW[d0.getDay()], tokens: 0, sessions: [] }
days.set(key, day)
}
const base = new Date(
d0.getFullYear(),
d0.getMonth(),
d0.getDate(),
).getTime()
day.tokens += s.tokens
day.sessions.push({
id,
project: s.project,
tokens: s.tokens,
start_min: Math.max(0, Math.round((s.firstTs - base) / 60000)),
end_min: Math.max(1, Math.round((s.lastTs - base) / 60000)),
})
}
for (const d of days.values()) {
// peak concurrency via 10-min buckets, capped at 24h for display
const b = new Array(144).fill(0)
for (const s of d.sessions) {
const lo = Math.min(143, Math.floor(s.start_min / 10))
const hi = Math.min(144, Math.ceil(Math.min(s.end_min, 1440) / 10))
for (let i = lo; i < hi; i++) b[i]++
}
d.peak = Math.max(0, ...b)
d.peak_at_min = d.peak > 0 ? b.indexOf(d.peak) * 10 : 0
d.sessions.sort((a, b) => a.start_min - b.start_min)
}
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date))
}
function promptTotal(r) { function promptTotal(r) {
return ( return (
r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens

View File

@@ -102,6 +102,42 @@
color: var(--dim); margin: 6px 0; } color: var(--dim); margin: 6px 0; }
.callout b, .callout code { color: var(--term-fg); } .callout b, .callout code { color: var(--term-fg); }
/* ——— day pills + session gantt ——— */
.days { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.dpill { flex: 1; min-width: 84px; max-width: 140px; background: none;
border: 1px solid var(--subtle); border-radius: 4px;
padding: 9px 6px; font: inherit; color: var(--dim);
cursor: pointer; text-align: center; }
.dpill:hover { border-color: var(--dim); background: var(--hover); }
.dpill .dow { font-size: 10px; color: var(--subtle); display: block; }
.dpill .date { font-size: 11px; color: var(--term-fg); font-weight: 500;
display: block; margin: 2px 0 4px; }
.dpill .pct { font-size: 16px; font-weight: 700; color: var(--term-fg); display: block; }
.dpill .ns { font-size: 10px; color: var(--subtle); display: block; margin-top: 2px; }
.dpill.heaviest .pct { color: var(--clay); }
.dpill.sel { border-color: var(--clay); background: rgba(217,119,87,0.10); }
.gantt-hd { display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 6px; }
.gantt-hd .day { color: var(--term-fg); font-weight: 500; }
.gantt-hd .stats { font-size: 11px; color: var(--dim); }
.gantt-hd .stats b { color: var(--clay); }
.gantt { position: relative; border-top: 1px solid var(--outline);
border-bottom: 1px solid var(--outline); min-height: 32px; }
.lane { position: relative; height: 16px;
border-bottom: 1px dashed rgba(255,255,255,0.04); }
.seg { position: absolute; top: 2px; height: 12px; border-radius: 2px;
opacity: .85; cursor: crosshair; }
.seg:hover { opacity: 1; outline: 1px solid var(--term-fg); z-index: 2; }
.gantt-rule { position: absolute; top: 0; bottom: 0; width: 0;
border-left: 1px dashed var(--subtle); opacity: .4;
pointer-events: none; }
.gantt-axis { display: flex; justify-content: space-between;
font-size: 10px; color: var(--subtle); padding: 4px 0; }
.gantt-leg { font-size: 10px; color: var(--subtle); margin-top: 8px;
display: flex; gap: 14px; flex-wrap: wrap; }
.gantt-leg .sw { display: inline-block; width: 14px; height: 10px;
border-radius: 2px; vertical-align: middle; margin-right: 4px; }
/* ——— block-char bars ——— */ /* ——— block-char bars ——— */
.bar { display: grid; grid-template-columns: 26ch 1fr 8ch; gap: 14px; .bar { display: grid; grid-template-columns: 26ch 1fr 8ch; gap: 14px;
padding: 2px 0; align-items: center; } padding: 2px 0; align-items: center; }
@@ -231,6 +267,21 @@
<div class="section-body" id="project-bars"></div> <div class="section-body" id="project-bars"></div>
</section> </section>
<section id="timeline-section">
<div class="hr"></div>
<h2>session timeline by day<span class="hint">click a day · ←/→ to navigate</span></h2>
<div class="section-body">
<div class="days" id="day-pills"></div>
<div class="gantt-hd">
<span class="day" id="g-day"></span>
<span class="stats" id="g-stats"></span>
</div>
<div class="gantt-axis"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
<div class="gantt" id="gantt"></div>
<div class="gantt-leg" id="gantt-leg"></div>
</div>
</section>
<section> <section>
<div class="hr"></div> <div class="hr"></div>
<h2>most expensive prompts<span class="hint">click to expand context</span></h2> <h2>most expensive prompts<span class="hint">click to expand context</span></h2>
@@ -335,6 +386,65 @@
`<div class="val">${typeof v==='number'&&v>=1e4?fmt(v):v}</div>`+ `<div class="val">${typeof v==='number'&&v>=1e4?fmt(v):v}</div>`+
(d?`<div class="detail">${d}</div>`:'')+`</div>`).join(''); (d?`<div class="detail">${d}</div>`:'')+`</div>`).join('');
// session timeline by day
(function() {
const days = (DATA.by_day||[]).slice(-14);
if (!days.length) { $('timeline-section').style.display='none'; return; }
const PCOL = ['rgb(177,185,249)','rgb(78,186,101)','#D97757','rgb(255,193,7)',
'rgb(255,107,128)','#9b8cff','#6ec1d6','#c792ea'];
const dayTotal = days.reduce((a,d)=>a+d.tokens,0) || 1;
const tokMax = Math.max(...days.map(d=>d.tokens));
const projects = [...new Set(days.flatMap(d=>d.sessions.map(s=>s.project)))];
const colorOf = p => PCOL[projects.indexOf(p)%PCOL.length];
const hhmm = m => (m>=1440?`+${Math.floor(m/1440)}d `:'') +
`${String(Math.floor(m/60)%24).padStart(2,'0')}:${String(m%60).padStart(2,'0')}`;
const md = iso => { const [,mo,da]=iso.split('-'); return `${MON[+mo-1]} ${+da}`; };
let sel = days.findIndex(d=>d.tokens===tokMax);
function pills() {
$('day-pills').innerHTML = days.map((d,i)=>
`<button class="dpill${d.tokens===tokMax?' heaviest':''}${i===sel?' sel':''}" data-i="${i}">`+
`<span class="dow">${esc(d.dow)}</span>`+
`<span class="date">${esc(md(d.date))}</span>`+
`<span class="pct">${(100*d.tokens/dayTotal).toFixed(1)}%</span>`+
`<span class="ns">${d.sessions.length} sess</span></button>`
).join('');
$('day-pills').querySelectorAll('.dpill').forEach(el=>
el.onclick=()=>{sel=+el.dataset.i;pills();gantt();});
}
function gantt() {
const d = days[sel], DAY = 1440;
$('g-day').textContent = `${d.dow} ${md(d.date)}`;
$('g-stats').innerHTML = `${d.sessions.length} sessions · ${fmt(d.tokens)} tokens`+
` · peak <b>${d.peak}</b> concurrent at <b>${hhmm(d.peak_at_min)}</b>`;
const lanes = [];
for (const s of d.sessions) {
let placed = false;
for (const L of lanes) if (L[L.length-1].end_min <= s.start_min) { L.push(s); placed=true; break; }
if (!placed) lanes.push([s]);
}
let h = '';
for (let t=0;t<=24;t+=6) h += `<div class="gantt-rule" style="left:${100*t/24}%"></div>`;
h += lanes.map(L=>`<div class="lane">${L.map(s=>{
const end = Math.min(s.end_min, DAY);
const w = Math.max(0.15, 100*(end-s.start_min)/DAY);
const tip = `folder: ${short(s.project)}\n`+
`${hhmm(s.start_min)}${hhmm(s.end_min)} · ${fmt(s.tokens)} tokens\n`+
`session ${s.id}`;
return `<span class="seg" style="left:${100*s.start_min/DAY}%;width:${w}%;`+
`background:${colorOf(s.project)}" title="${esc(tip)}"></span>`;
}).join('')}</div>`).join('');
$('gantt').innerHTML = h || '<div class="callout">no sessions</div>';
}
document.addEventListener('keydown',e=>{
if (e.key==='ArrowRight'&&sel<days.length-1){sel++;pills();gantt();e.preventDefault();}
if (e.key==='ArrowLeft'&&sel>0){sel--;pills();gantt();e.preventDefault();}
});
$('gantt-leg').innerHTML = projects.slice(0,12).map(p=>
`<span><span class="sw" style="background:${colorOf(p)}"></span>${esc(short(p))}</span>`).join('');
pills(); gantt();
})();
// block-char project bars // block-char project bars
(function() { (function() {
const W = 48; const W = 48;
@@ -366,57 +476,52 @@
return h + '</div>'; return h + '</div>';
} }
// top prompts — share of grand total // expandable drill-down list with "show N more" toggle
(function() { function drillList(hostId, items, rowFn, empty) {
const ps = (DATA.top_prompts||[]).slice(0,100);
const SHOW = 5; const SHOW = 5;
const row = p => { const host = $(hostId);
const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read; if (!items.length) { host.innerHTML = `<div class="callout">${empty}</div>`; return; }
return `<details><summary>`+ const head = items.slice(0,SHOW).map(rowFn).join('');
`<span class="amt">${share(p.total_tokens)}</span>`+ const rest = items.slice(SHOW).map(rowFn).join('');
`<span class="desc">${esc(p.text)}</span>`+ host.innerHTML = head + (rest
`<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+ ? `<div hidden>${rest}</div><button class="more-btn">show ${items.length-SHOW} more</button>`
(p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+ : '');
` · ${pct(p.input.cache_read,inTot)} cached</span>`+ const btn = host.querySelector('.more-btn');
`</summary><div class="body">`+
renderContext(p.context)+
`<div>session <code>${esc(p.session)}</code></div>`+
`<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+
`cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+
`</div></details>`;
};
const head = ps.slice(0,SHOW).map(row).join('');
const rest = ps.slice(SHOW).map(row).join('');
$('top-prompts').innerHTML = ps.length
? head + (rest
? `<div id="tp-rest" hidden>${rest}</div>`+
`<button id="tp-more" class="more-btn">show ${ps.length-SHOW} more</button>`
: '')
: '<div class="callout">No prompts in range.</div>';
const btn = $('tp-more');
if (btn) btn.onclick = () => { if (btn) btn.onclick = () => {
const r = $('tp-rest'); r.hidden = !r.hidden; const r = btn.previousElementSibling; r.hidden = !r.hidden;
btn.textContent = r.hidden ? `show ${ps.length-SHOW} more` : 'show less'; btn.textContent = r.hidden ? `show ${items.length-SHOW} more` : 'show less';
}; };
})(); }
// cache breaks drillList('top-prompts', (DATA.top_prompts||[]).slice(0,100), p => {
(function() { const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read;
const bs = (DATA.cache_breaks||[]).slice(0,100); return `<details><summary>`+
$('cache-breaks').innerHTML = bs.map(b => `<span class="amt">${share(p.total_tokens)}</span>`+
`<details><summary>`+ `<span class="desc">${esc(p.text)}</span>`+
`<span class="amt">${fmt(b.uncached)}</span>`+ `<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+
`<span class="desc">${esc(short(b.project))} · `+ (p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+
`${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+ ` · ${pct(p.input.cache_read,inTot)} cached</span>`+
`<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+
`</summary><div class="body">`+ `</summary><div class="body">`+
renderContext(b.context, renderContext(p.context)+
`<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+ `<div>session <code>${esc(p.session)}</code></div>`+
`(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+ `<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+
`<div>session <code>${esc(b.session)}</code></div>`+ `cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+
`</div></details>` `</div></details>`;
).join('') || '<div class="callout">No cache breaks over threshold.</div>'; }, 'No prompts in range.');
})();
drillList('cache-breaks', (DATA.cache_breaks||[]).slice(0,100), b =>
`<details><summary>`+
`<span class="amt">${fmt(b.uncached)}</span>`+
`<span class="desc">${esc(short(b.project))} · `+
`${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+
`<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+
`</summary><div class="body">`+
renderContext(b.context,
`<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+
`(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+
`<div>session <code>${esc(b.session)}</code></div>`+
`</div></details>`,
'No cache breaks over threshold.');
// sortable table // sortable table
function table(el, cols, rows) { function table(el, cols, rows) {