@bunshin-cc/bunshin 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -6508,9 +6508,11 @@ var route_exports = {};
6508
6508
  __export(route_exports, {
6509
6509
  ROUTER_MODEL: () => ROUTER_MODEL,
6510
6510
  lastReply: () => lastReply,
6511
+ localDay: () => localDay,
6511
6512
  promptContext: () => promptContext,
6512
6513
  readAnswer: () => readAnswer,
6513
6514
  readJob: () => readJob,
6515
+ readStats: () => readStats,
6514
6516
  routeJob: () => routeJob,
6515
6517
  sweepRouter: () => sweepRouter,
6516
6518
  writeJob: () => writeJob
@@ -6636,17 +6638,24 @@ async function routeJob(store, sessionId, seq) {
6636
6638
  const job = readJob(v2, sessionId);
6637
6639
  if (!job || job.seq !== seq || job.cancelled) return null;
6638
6640
  const started = Date.parse(job.at) || Date.now();
6639
- const write = (a) => {
6640
- const answer = { seq, at: (/* @__PURE__ */ new Date()).toISOString(), ms: Date.now() - started, ...a };
6641
- writeJsonAtomic(answerFile(v2, sessionId, seq), answer);
6642
- v2.log("v2-router", { session: sessionId, seq, serve: answer.serve ?? null, rules: answer.rules ?? [], hint: answer.hint ?? [], ms: answer.ms, modelMs: answer.modelMs, error: answer.error, dense: job.dense?.serve ?? null, usage: answer.usage });
6643
- return answer;
6644
- };
6641
+ const model = config.recall.routerModel || process.env.BUNSHIN_ROUTER_MODEL || ROUTER_MODEL;
6642
+ const { picks, indexHash } = await askRouter(store, v2, sessionId, model);
6643
+ const answer = { seq, at: (/* @__PURE__ */ new Date()).toISOString(), ms: Date.now() - started, ...picks };
6644
+ writeJsonAtomic(answerFile(v2, sessionId, seq), answer);
6645
+ const dense = (readJob(v2, sessionId) ?? job).dense;
6646
+ v2.log("v2-router", { session: sessionId, seq, serve: answer.serve ?? null, rules: answer.rules ?? [], hint: answer.hint ?? [], ms: answer.ms, modelMs: answer.modelMs, error: answer.error, dense: dense?.serve ?? null, denseHint: dense?.hint ?? [], usage: answer.usage, costUsd: answer.costUsd });
6647
+ await recordStats(v2, sessionId, answer, model, indexHash);
6648
+ return answer;
6649
+ }
6650
+ async function askRouter(store, v2, sessionId, model) {
6651
+ const config = store.config();
6652
+ let indexHash;
6645
6653
  try {
6646
6654
  const session = v2.readSession(sessionId);
6647
- if (!session?.lastPrompt) return write({ error: "no prompt in the session file" });
6648
- if (!canCall(config)) return write({ error: "offline: the router's index comes from the server" });
6655
+ if (!session?.lastPrompt) return { picks: { error: "no prompt in the session file" } };
6656
+ if (!canCall(config)) return { picks: { error: "offline: the router's index comes from the server" } };
6649
6657
  const index = await routerIndex(store, v2, session.source ?? "");
6658
+ indexHash = index.hash;
6650
6659
  const systemFile = path11.join(dir(v2), `system-${index.hash}.txt`);
6651
6660
  if (!fs16.existsSync(systemFile)) fs16.writeFileSync(systemFile, index.system);
6652
6661
  const cwd = path11.join(dir(v2), "cwd");
@@ -6654,16 +6663,44 @@ async function routeJob(store, sessionId, seq) {
6654
6663
  const inContext2 = session.inContext ?? [...session.loaded, ...session.pushed];
6655
6664
  const titles = new Map((v2.readBundle()?.sections ?? []).map((s) => [s.id, s.skill?.name ?? s.title]));
6656
6665
  const message = routerMessage(session.lastPrompt, promptContext(session), inContext2.map((id) => titles.get(id)).filter((t) => !!t));
6657
- const res = await askClaude(systemFile, message, config.recall.routerModel || process.env.BUNSHIN_ROUTER_MODEL || ROUTER_MODEL, cwd);
6666
+ const res = await askClaude(systemFile, message, model, cwd);
6658
6667
  const u = res.usage ?? {};
6659
6668
  const usage = { input: u.input_tokens ?? 0, cacheRead: u.cache_read_input_tokens ?? 0, cacheWrite: u.cache_creation_input_tokens ?? 0, output: u.output_tokens ?? 0, ...u.output_tokens_details?.thinking_tokens ? { thinking: u.output_tokens_details.thinking_tokens } : {} };
6660
- const meta = { ...res.duration_ms ? { modelMs: res.duration_ms } : {}, usage };
6661
- if (res.is_error) return write({ ...meta, error: `claude: ${(res.result ?? "error").slice(0, 300)}` });
6669
+ const meta = { ...res.duration_ms ? { modelMs: res.duration_ms } : {}, usage, ...typeof res.total_cost_usd === "number" ? { costUsd: res.total_cost_usd } : {} };
6670
+ if (res.is_error) return { picks: { ...meta, error: `claude: ${(res.result ?? "error").slice(0, 300)}` }, indexHash };
6662
6671
  const picks = parseRouterAnswer(res.result ?? "", index.items, inContext2);
6663
- if (!picks) return write({ ...meta, error: `not JSON: ${(res.result ?? "").slice(0, 120)}` });
6664
- return write({ ...picks, ...meta });
6672
+ if (!picks) return { picks: { ...meta, error: `not JSON: ${(res.result ?? "").slice(0, 120)}` }, indexHash };
6673
+ return { picks: { ...picks, ...meta }, indexHash };
6665
6674
  } catch (err) {
6666
- return write({ error: (err instanceof Error ? err.message : String(err)).slice(0, 300) });
6675
+ return { picks: { error: (err instanceof Error ? err.message : String(err)).slice(0, 300) }, ...indexHash ? { indexHash } : {} };
6676
+ }
6677
+ }
6678
+ async function recordStats(v2, sessionId, a, model, indexHash) {
6679
+ let release = null;
6680
+ for (let i = 0; i < 40 && !(release = v2.tryLock("router-stats", 3e4)); i++) await new Promise((r) => setTimeout(r, 50));
6681
+ try {
6682
+ const day = localDay();
6683
+ const prev = readStats(v2);
6684
+ const stats = prev?.v === 1 && prev.day === day ? prev : { v: 1, day, model, today: emptyTally(), sessions: {} };
6685
+ const u = a.usage ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
6686
+ const add = (t) => {
6687
+ t.calls++;
6688
+ if (a.error) t.errors++;
6689
+ t.costUsd += a.costUsd ?? 0;
6690
+ t.input += u.input;
6691
+ t.cacheRead += u.cacheRead;
6692
+ t.cacheWrite += u.cacheWrite;
6693
+ t.output += u.output;
6694
+ t.msTotal += a.ms;
6695
+ };
6696
+ add(stats.today);
6697
+ add(stats.sessions[sessionId] ??= emptyTally());
6698
+ stats.model = model;
6699
+ if (a.usage || !stats.last) stats.last = { at: a.at, session: sessionId, ms: a.ms, costUsd: a.costUsd ?? 0, input: u.input, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, ...indexHash ? { indexHash } : {}, ...a.error ? { error: a.error } : {} };
6700
+ writeJsonAtomic(path11.join(dir(v2), "stats.json"), stats);
6701
+ } catch {
6702
+ } finally {
6703
+ release?.();
6667
6704
  }
6668
6705
  }
6669
6706
  function sweepRouter(v2, now = Date.now()) {
@@ -6676,7 +6713,7 @@ function sweepRouter(v2, now = Date.now()) {
6676
6713
  } catch {
6677
6714
  }
6678
6715
  }
6679
- var ROUTER_MODEL, INDEX_TTL_MS, CLAUDE_TIMEOUT_MS, dir, safe2, jobFile, answerFile, readJob, writeJob, readAnswer;
6716
+ var ROUTER_MODEL, INDEX_TTL_MS, CLAUDE_TIMEOUT_MS, dir, safe2, jobFile, answerFile, readJob, writeJob, readAnswer, emptyTally, localDay, readStats;
6680
6717
  var init_route = __esm({
6681
6718
  "src/v2/route.ts"() {
6682
6719
  "use strict";
@@ -6696,6 +6733,9 @@ var init_route = __esm({
6696
6733
  readJob = (v2, session) => readJson2(jobFile(v2, session));
6697
6734
  writeJob = (v2, session, job) => writeJsonAtomic(jobFile(v2, session), job);
6698
6735
  readAnswer = (v2, session, seq) => readJson2(answerFile(v2, session, seq));
6736
+ emptyTally = () => ({ calls: 0, errors: 0, costUsd: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, msTotal: 0 });
6737
+ localDay = (d = /* @__PURE__ */ new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6738
+ readStats = (v2) => readJson2(path11.join(dir(v2), "stats.json"));
6699
6739
  }
6700
6740
  });
6701
6741
 
@@ -7324,7 +7364,7 @@ async function routedText(v2, ctx, session) {
7324
7364
  const rules = (await Promise.all(fresh(fallback ? [] : answer.rules ?? []).filter((id) => id !== doc?.id).map(find))).filter((s) => !!s);
7325
7365
  const hints = fresh(fallback ? job.dense?.hint ?? [] : answer.hint ?? []).filter((id) => id !== doc?.id && !session.hinted?.includes(id));
7326
7366
  const hintTitles = hints.map((id) => view.sections.find((s) => s.id === id)?.title).filter((t) => !!t);
7327
- v2.log("v2-router-delivered", { session: session.sessionId, seq: job.seq, tool: ctx.input.tool_name, afterMs: Date.now() - Date.parse(job.at), serve: doc?.id ?? null, rules: rules.map((r) => r.id), hint: hints, fallback, ...answer.error ? { error: answer.error } : {} });
7367
+ v2.log("v2-router-delivered", { session: session.sessionId, seq: job.seq, tool: ctx.input.tool_name, afterMs: Date.now() - Date.parse(job.at), serve: doc?.id ?? null, rules: rules.map((r) => r.id), hint: hints, fallback, dense: job.dense?.serve ?? null, ...answer.error ? { error: answer.error } : {} });
7328
7368
  const ids = [...doc ? [doc.id] : [], ...rules.map((r) => r.id)];
7329
7369
  if (!ids.length && !hintTitles.length) return "";
7330
7370
  v2.updateSession(session.sessionId, ctx.cwd, (s) => {
@@ -6,9 +6,14 @@
6
6
  // checkpoints, reviews waiting. This mod draws it:
7
7
  //
8
8
  // above the prompt one line: the latest document served and why, then the session's counts
9
- // /bunshin a pane: everything served, the lessons, recent events; Learn now, Sync, Review
9
+ // (and, with the claude-cli recall router, whether its cache is warm and today's cost)
10
+ // /bunshin a pane: everything served, the lessons, recent events, the router's calls and
11
+ // cost; Learn now, Sync, Review
10
12
  // toasts library updates, lessons learned, reviews waiting, the /learn nudge
11
13
  //
14
+ // The claude-cli router (packages/cli/src/v2/route.ts) keeps its calls, tokens and cost at API
15
+ // prices in .bunshin/v2/router/stats.json; the index stays cached for an hour after each call.
16
+ //
12
17
  // While it runs it writes a heartbeat beside the feed (<session>.mod.json); the CLI then stops
13
18
  // printing its plain one-line fallback (systemMessage), so nothing is said twice.
14
19
 
@@ -16,6 +21,9 @@ const FEED = { plugin: "bunshin-ui", key: "feed" };
16
21
  const WHERE = { plugin: "bunshin-ui", key: "where" };
17
22
  const SEEN = { plugin: "bunshin-ui", key: "seen" };
18
23
  const PANEL = { plugin: "bunshin-ui", key: "panel" };
24
+ const ROUTER = { plugin: "bunshin-ui", key: "router" };
25
+ /** Claude Code caches the router's index for an hour; each call that reads it starts the hour again. */
26
+ const CACHE_TTL_MS = 60 * 60_000;
19
27
 
20
28
  const PANE_ID = "bunshin";
21
29
  const POLL_MS = 15_000;
@@ -90,16 +98,18 @@ export function register(on) {
90
98
  if (e.props.hasSurvey) return next(e);
91
99
  const { value: feed } = await $.state.get(FEED);
92
100
  if (!feed) return next(e);
101
+ const { value: router = null } = await $.state.get(ROUTER);
93
102
  const { Box, Text } = $.ui.resolve(e);
94
- return band(Box, Text, feed, e.props.bodyColumns);
103
+ return band(Box, Text, feed, e.props.bodyColumns, router);
95
104
  });
96
105
 
97
106
  on("ui.render", { component: "Pane" }, async ($, e, next) => {
98
107
  if (e.requestId !== PANE_ID || e.surface === "mobile") return next(e);
99
108
  const { value: feed } = await $.state.get(FEED);
100
109
  const { value: panel = { view: "session", busy: "", output: "" } } = await $.state.get(PANEL);
110
+ const { value: router = null } = await $.state.get(ROUTER);
101
111
  const { Box, Text, Button } = $.ui.resolve(e);
102
- return pane({ Box, Text, Button }, feed, panel, e.props.bodyColumns, e.props.scroll?.bodyRows ?? 30);
112
+ return pane({ Box, Text, Button }, feed, panel, e.props.bodyColumns, e.props.scroll?.bodyRows ?? 30, router);
103
113
  });
104
114
  }
105
115
 
@@ -149,6 +159,7 @@ async function refresh($, { first = false } = {}) {
149
159
  feed = null;
150
160
  }
151
161
  const now = await $.clock.now();
162
+ await refreshRouter($, where, now);
152
163
  if (now - lastBeat >= BEAT_MS) {
153
164
  lastBeat = now;
154
165
  await $.fs.write(`${where.store}/ui/${safe(where.sessionId)}.mod.json`, JSON.stringify({ at: new Date(now).toISOString(), mod: "bunshin-ui" })).catch(() => undefined);
@@ -176,6 +187,48 @@ async function refresh($, { first = false } = {}) {
176
187
  }
177
188
  }
178
189
 
190
+ /** The local date, YYYY-MM-DD, as the CLI writes it. */
191
+ const localDay = (ms) => {
192
+ const d = new Date(ms);
193
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
194
+ };
195
+
196
+ /**
197
+ * What the router's stats say now: is the cache warm (the latest call read or wrote it under an
198
+ * hour ago) and for how much longer, and today's and this session's calls and cost. Null when the
199
+ * claude-cli router has not run. Written to state only when it reads differently (at most a minute).
200
+ */
201
+ function routerView(stats, sessionId, now) {
202
+ if (!stats || stats.v !== 1) return null;
203
+ const fresh = stats.day === localDay(now);
204
+ const tally = (t) => (t && fresh ? { calls: t.calls, errors: t.errors, costUsd: t.costUsd, avgMs: t.calls ? Math.round(t.msTotal / t.calls) : 0 } : { calls: 0, errors: 0, costUsd: 0, avgMs: 0 });
205
+ const last = stats.last;
206
+ const age = last ? now - Date.parse(last.at) : Number.POSITIVE_INFINITY;
207
+ const cached = last ? last.cacheRead + last.cacheWrite : 0;
208
+ const warm = !!last && cached > 0 && age >= 0 && age < CACHE_TTL_MS;
209
+ return {
210
+ model: stats.model,
211
+ warm,
212
+ minutesLeft: warm ? Math.max(1, Math.ceil((CACHE_TTL_MS - age) / 60_000)) : 0,
213
+ last: last ? { cacheRead: last.cacheRead, tokens: cached + last.input, costUsd: last.costUsd, ms: last.ms, at: last.at } : null,
214
+ today: tally(stats.today),
215
+ session: tally(stats.sessions?.[sessionId]),
216
+ };
217
+ }
218
+
219
+ async function refreshRouter($, where, now) {
220
+ const file = `${where.store}/router/stats.json`;
221
+ let stats = null;
222
+ try {
223
+ if (await $.fs.exists(file)) stats = JSON.parse(await $.fs.read(file));
224
+ } catch {
225
+ stats = null;
226
+ }
227
+ const view = routerView(stats, where.sessionId, now);
228
+ const { value: prev = null } = await $.state.get(ROUTER);
229
+ if (JSON.stringify(prev) !== JSON.stringify(view)) await $.state.set(ROUTER, view);
230
+ }
231
+
179
232
  /** Run the CLI for the session (its argv is in the feed, so the runtime copy the hooks use). */
180
233
  async function act($, what) {
181
234
  const { value: where } = await $.state.get(WHERE);
@@ -211,6 +264,11 @@ async function showReview($) {
211
264
  // ── drawing ─────────────────────────────────────────────────────────────────────────────────────
212
265
 
213
266
  const label = (s) => (s.skill ? `${s.skill} skill` : `${s.title}`);
267
+ const usd = (n) => `$${n < 0.1 ? n.toFixed(3) : n.toFixed(2)}`;
268
+ const kTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
269
+ const modelName = (m) => String(m ?? "").replace(/^claude-/, "").replace(/-(\d+)-(\d+)$/, " $1.$2").replace(/^./, (c) => c.toUpperCase());
270
+ /** The router in the band's few columns: "cache warm 52m · $0.04 today". */
271
+ const routerShort = (r) => (r ? `${r.warm ? `cache warm ${r.minutesLeft}m` : "cache cold"} · ${usd(r.today.costUsd)} today` : "");
214
272
  const clip = (s, n) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s);
215
273
  const hhmm = (iso) => String(iso).slice(11, 16);
216
274
 
@@ -237,10 +295,12 @@ const shortWhy = (why) =>
237
295
  * ◆ bunshin complete-ticket skill embedders agree lib@209 · 1 served · 7 to review
238
296
  * ◇ bunshin maybe agent-grid · manage-agent-fleet skill lib@209 · 7 to review
239
297
  *
240
- * What does not fit is dropped whole, least useful first: the quiet counts, then the reason (never
241
- * the reviews waiting, in yellow); only then does the name end in "…".
298
+ * ◆ bunshin complete-ticket skill router lib@249 · 1 served · cache warm 52m · $0.04 today
299
+ *
300
+ * What does not fit is dropped whole, least useful first: the quiet counts, then the reason, then
301
+ * the router's cache and cost (never the reviews waiting, in yellow); only then does the name end in "…".
242
302
  */
243
- function band(Box, Text, feed, columns) {
303
+ function band(Box, Text, feed, columns, router = null) {
244
304
  const latest = feed.served.find((s) => s.how !== "hint");
245
305
  const hint = feed.served[0]?.how === "hint" ? feed.served.filter((s) => s.how === "hint" && s.at === feed.served[0].at) : [];
246
306
  const served = feed.served.filter((s) => s.how !== "hint").length;
@@ -250,13 +310,15 @@ function band(Box, Text, feed, columns) {
250
310
  const why = latest?.why ? ` ${shortWhy(latest.why)}` : "";
251
311
  // The cells each piece takes: "◆ bunshin " (11), the name, the reason; the counts plus a 2-cell gap.
252
312
  const room = columns - 2;
253
- const fits = (withWhy, withQuiet) => {
254
- const right = [withQuiet ? quiet : "", review].filter(Boolean).join(" · ");
313
+ const cache = routerShort(router);
314
+ const fits = (withWhy, withQuiet, withCache) => {
315
+ const right = [withQuiet ? quiet : "", withCache ? cache : "", review].filter(Boolean).join(" · ");
255
316
  return 11 + name.length + (withWhy ? why.length : 0) + (right ? right.length + 2 : 0) <= room;
256
317
  };
257
- // Keep, in order: the name, the reviews waiting, the reason, the quiet counts.
258
- const showWhy = !!why && fits(true, false);
259
- const showQuiet = !!quiet && fits(showWhy, true);
318
+ // Keep, in order: the name, the reviews waiting, the router's cache and cost, the reason, the quiet counts.
319
+ const showCache = !!cache && fits(false, false, true);
320
+ const showWhy = !!why && fits(true, false, showCache);
321
+ const showQuiet = !!quiet && fits(showWhy, true, showCache);
260
322
 
261
323
  const left = [];
262
324
  if (latest) {
@@ -273,6 +335,11 @@ function band(Box, Text, feed, columns) {
273
335
  }
274
336
  const right = [];
275
337
  if (showQuiet) right.push(Text({ dimColor: true, children: quiet }));
338
+ if (showCache) {
339
+ if (right.length) right.push(Text({ dimColor: true, children: " · " }));
340
+ right.push(router.warm ? Text({ color: "green", children: `cache warm ${router.minutesLeft}m` }) : Text({ dimColor: true, children: "cache cold" }));
341
+ right.push(Text({ dimColor: true, children: ` · ${usd(router.today.costUsd)} today` }));
342
+ }
276
343
  if (review) {
277
344
  if (right.length) right.push(Text({ dimColor: true, children: " · " }));
278
345
  right.push(Text({ color: "yellow", children: review }));
@@ -300,7 +367,26 @@ function heading(Text, text) {
300
367
  return Text({ bold: true, color: BRAND, children: text });
301
368
  }
302
369
 
303
- function pane({ Box, Text, Button }, feed, panel, columns, rows) {
370
+ /** The pane's router section: the cache, then today's and this session's calls and cost. */
371
+ function routerLines(Text, r, width) {
372
+ const out = [Text({ children: " " }), heading(Text, `Recall router · ${modelName(r.model)} on your Claude plan`)];
373
+ const hit = r.last && r.last.tokens ? Math.round((100 * r.last.cacheRead) / r.last.tokens) : 0;
374
+ out.push(
375
+ Text({
376
+ wrap: "truncate-end",
377
+ children: r.warm
378
+ ? [Text({ children: " cache " }), Text({ color: "green", children: `warm, ${r.minutesLeft} min left` }), Text({ dimColor: true, children: r.last ? ` · last call read ${kTokens(r.last.cacheRead)} of ${kTokens(r.last.tokens)} tokens from cache (${hit}%)` : "" })]
379
+ : [Text({ children: " cache " }), Text({ color: "yellow", children: "cold" }), Text({ dimColor: true, children: " · the next call writes the index again (it stays cached an hour after each call)" })],
380
+ }),
381
+ );
382
+ const t = r.today;
383
+ const each = t.calls ? ` · ≈${usd(t.costUsd / t.calls)} a call · ${(t.avgMs / 1000).toFixed(1)} s on average` : "";
384
+ out.push(Text({ children: clip(` today ${t.calls} call${t.calls === 1 ? "" : "s"} · ${usd(t.costUsd)} at API prices${each}${t.errors ? ` · ${t.errors} failed` : ""}`, width), wrap: "truncate-end" }));
385
+ out.push(Text({ dimColor: true, children: clip(` this session ${r.session.calls} call${r.session.calls === 1 ? "" : "s"} · ${usd(r.session.costUsd)}`, width), wrap: "truncate-end" }));
386
+ return out;
387
+ }
388
+
389
+ function pane({ Box, Text, Button }, feed, panel, columns, rows, router = null) {
304
390
  const width = Math.max(30, columns - 2);
305
391
  const body = [];
306
392
  if (!feed) {
@@ -333,6 +419,7 @@ function pane({ Box, Text, Button }, feed, panel, columns, rows) {
333
419
  body.push(heading(Text, "Learned from this session"));
334
420
  if (!feed.lessons.length) body.push(Text({ dimColor: true, children: feed.reflectedAt ? " nothing new" : " not reflected on yet (Learn now, or at session end)" }));
335
421
  for (const l of feed.lessons.slice(-6)) body.push(Text({ children: clip(` ${l.status.padEnd(10)} ${l.target}: ${l.summary}`, width), wrap: "truncate-end" }));
422
+ if (router) body.push(...routerLines(Text, router, width));
336
423
  const recent = feed.events.filter((x) => x.kind !== "served" && x.kind !== "hint" && x.kind !== "push").slice(-4);
337
424
  if (recent.length) {
338
425
  body.push(Text({ children: " " }));
@@ -18,6 +18,16 @@ export type BunshinUiFeed = {
18
18
  };
19
19
  export type BunshinUiWhere = { store: string; project: string; sessionId: string };
20
20
  export type BunshinUiPanel = { view: "session" | "review"; busy: string; output: string };
21
+ export type BunshinUiRouterTally = { calls: number; errors: number; costUsd: number; avgMs: number };
22
+ /** The claude-cli recall router as drawn: its cache and its cost (router/stats.json, read). */
23
+ export type BunshinUiRouter = {
24
+ model: string;
25
+ warm: boolean;
26
+ minutesLeft: number;
27
+ last: { cacheRead: number; tokens: number; costUsd: number; ms: number; at: string } | null;
28
+ today: BunshinUiRouterTally;
29
+ session: BunshinUiRouterTally;
30
+ };
21
31
 
22
32
  declare module "claude-code" {
23
33
  interface PluginState {
@@ -26,6 +36,7 @@ declare module "claude-code" {
26
36
  where: BunshinUiWhere | null;
27
37
  seen: number;
28
38
  panel: BunshinUiPanel;
39
+ router: BunshinUiRouter | null;
29
40
  };
30
41
  }
31
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunshin-cc/bunshin",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Shared memory for company AI agents. Connect Codex, Claude Code, or Pi to Bunshin.",
5
5
  "type": "module",
6
6
  "bin": {