SLOPSHOPPER

coach

/coach: live cost, cache, context and subagent stats, SDLC habit checks and architect lessons; one tip after a turn when it matters.

newpaneguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-09eshin087/claude-code-mods/coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coach
│ ┃ Coach ✕ › fix the failing auth test and add an audit log call │ ┃ Coach app · 1 turns More ✕ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ● Cache 93% ━━━━━━━━━━━━ reusing conte ⎿ Read 6 lines │ ┃ ● Context 49% ━━━━━━━━━━━━ room to spare ⏺ Update(src/auth.ts) │ ┃ ● Cost $0.42 $0.42 per turn ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Habits · Plan · Branch ✗ Tests ✗ Commi ⎿ 3 pass, 1 fail │ ┃ │ ┃ Do next Nothing urgent: keep going ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Tokens 2k fresh 91k cached 4k cache-write ✻ Worked for 42s · done 4:20 PM │ ┃ Model claude-opus-5-5 │ ┃ Lesson Threat modelling in five minutes › /coach │ ┃ For each feature: what are the asse ⎿ coach: Coach opened. │ ┃ could abuse it, what is the worst c │ ┃ what control stops it (STRIDE is a │ ┃ checklist). │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Coach
Coach app · 1 turns More ✕ ● Cache 93% ━━━━━━━━━━━━ reusing context well ● Context 49% ━━━━━━━━━━━━ room to spare ● Cost $0.42 $0.42 per turn Habits · Plan · Branch ✗ Tests ✗ Commits · PR Do next Nothing urgent: keep going Tokens 2k fresh 91k cached 4k cache-write 1k out Model claude-opus-5-5 Lesson Threat modelling in five minutes For each feature: what are the assets, who could ab what is the worst case, and what control stops it ( a handy checklist).
README

Coach

What it does: shows the numbers behind your sessions that drive cost and quality, checks your habits against a normal software lifecycle (SDLC), and teaches one architect-level concept at a time, tied to what you just did. The aim is to carry vibe-coding skill into real engineering work.

Where you see it:

  • /coach toggles the panel: `` ● Cache 92% ━━━━━━━━━━━ reusing context well ● Context 41% ━━━━━━━━━━━ room to spare ● Cost $3.20 $0.27 per turn Habits ✓ Plan ✓ Branch ✗ Tests ✓ Commits · PR Do next Run the tests: 8 edits since the last run Tokens 120k fresh 4.1M cached 300k cache-write 52k out Model claude-opus-5-5 effort high subagents 4 runs · 31% of tokens Lesson The test pyramid Many fast unit tests at the base, fewer integration tests… ``
  • Colors: green is good, yellow means watch it, red means act. In the token row, cached tokens are green (cheap), cache-writes yellow and output cyan.
  • More adds the lesson's "try next" and "at work" notes (with Another lesson) and a Claude Code pro tip (with Next tip and links).
  • One tip after a turn, only when a rule fires, as a dim 🎓 Coach · … row in the chat:
  • committed straight to main
  • cache hit under 50% on a big turn (with the likely cause)
  • context over 75% full
  • 5+ edits since tests last ran
  • a turn costing over ~$2
  • 10+ minutes of building with no plan
  • subagents using over half the tokens

The same tip waits at least 6 turns before repeating.

Glossary:

  • Prompt cache: the app re-sends the whole conversation every turn. A cache hit means that prefix was reused and billed at ~10% of fresh input. Hits break on model switches, CLAUDE.md or memory edits, /compact, or idle gaps (the cache expires).
  • Context fill: how much of the model's window the conversation uses. Fuller means slower, pricier, and lower quality near the limit.
  • Cost: API prices. On a subscription this is the value you used, not a bill, but it tracks how fast you burn your 5-hour window.

How it works: everything comes from what the app already reports (token usage per turn, session cost and context, tool calls such as edits, tests, commits and gh pr create). No model calls.

Cost: none.

Data saved: none on disk. Stats live for the session.

Source 2 files
hooks/register.tsx 607 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { CoachStats, CoachTokens } from '../types'
5
6// Watches the session's own numbers (token usage per turn, cache reads,
7// context fill, cost, subagents) and its habits (branch, tests, commits, PRs,
8// plans), all from events the app already reports: no model calls. After a
9// turn it adds one tip to the chat only when a rule fires; /coach shows it all.
10
11const PANE = 'coach'
12const COOLDOWN_TURNS = 6
13const stats = atom({ plugin: 'coach', key: 'stats' } as const, null)
14const lesson = atom({ plugin: 'coach', key: 'lesson' } as const, 0)
15const tipIndex = atom({ plugin: 'coach', key: 'tip' } as const, 0)
16const unseen = atom({ plugin: 'coach', key: 'unseen' } as const, false)
17const isOpen = atom({ plugin: 'coach', key: 'isOpen' } as const, false)
18const lastTip = atom({ plugin: 'coach', key: 'lastTip' } as const, null)
19const isMore = atom({ plugin: 'coach', key: 'isMore' } as const, false)
20
21const ZERO: CoachTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
22const TEST_RE = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bvitest\b|\bjest\b|\bpytest\b|playwright\s+test|\bcargo\s+test\b|\bgo\s+test\b|\bdotnet\s+test\b|\bnode\s+\S*tests?[\\/]\S+/i
23const COMMIT_RE = /\bgit\b(\s+-c\s+\S+)*\s+commit\b/i
24const PR_RE = /\bgh\s+pr\s+create\b/i
25const PERF_RE = /qa:perf|qa:scale|lighthouse|\bbench(mark)?\b|\bperf\b/i
26
27type Lesson = { id: string; title: string; idea: string; realWorld: string; tryNext: string }
28
29const LESSONS: Lesson[] = [
30  {
31    id: 'adr',
32    title: 'Architecture Decision Records (ADRs)',
33    idea: 'Write each significant decision down: context, options, the choice, and its consequences. One short page per decision, kept in the repo.',
34    realWorld: 'Teams use docs/adr/0001-*.md so newcomers learn why, not just what. It is a staple question in architect interviews.',
35    tryNext: 'Next time you pick between two approaches (say R2 vs tiles in the repo), ask Claude to draft an ADR before building.',
36  },
37  {
38    id: 'test-pyramid',
39    title: 'The test pyramid',
40    idea: 'Many fast unit tests at the base, fewer integration tests, very few slow end-to-end tests at the top.',
41    realWorld: 'Browser and Playwright checks are the top of the pyramid. Logic like route maths or parsers deserves fast unit tests underneath, so failures point at the exact function.',
42    tryNext: 'Pick one pure function that broke before and ask for unit tests that pin its edge cases.',
43  },
44  {
45    id: 'branching',
46    title: 'Branching strategy',
47    idea: 'Trunk-based development: short-lived branches merged often (behind feature flags if unfinished) instead of long-running ones.',
48    realWorld: 'Protected main + PR + green CI is the default at most companies. Direct commits to main are blocked by branch protection.',
49    tryNext: 'Turn on branch protection for main in GitHub (require PR + passing checks). Your merge guard hook is the local version of this.',
50  },
51  {
52    id: 'code-review',
53    title: 'Code review that scales',
54    idea: 'Small PRs (under ~400 changed lines) with a description of why, what, and how to test get faster, better reviews.',
55    realWorld: 'Reviewers look for design and risk, not typos; linters and tests catch the rest. One concern per PR makes reverts safe.',
56    tryNext: 'Ask Claude to write the PR description as: Problem · Approach · Risks · How to test · Screenshots.',
57  },
58  {
59    id: 'ci-cd',
60    title: 'CI/CD and ephemeral environments',
61    idea: 'CI runs checks on every push; CD ships automatically. A preview deploy per PR is an "ephemeral environment".',
62    realWorld: 'Your Vercel previews are exactly this. The next step teams take is making merge impossible unless CI is green.',
63    tryNext: 'Add a GitHub Actions workflow that runs npm test on every PR and make it a required check.',
64  },
65  {
66    id: 'perf-budget',
67    title: 'Performance budgets',
68    idea: 'Set explicit limits (frame time, bundle size, memory) and fail the build when they are crossed.',
69    realWorld: 'Budgets are measured on a quiet, consistent machine or in CI, never on a busy laptop, or the numbers are noise.',
70    tryNext: 'Write the budget down (e.g. 60 fps at 1080p, < 3 MB JS) and have the perf script compare against it.',
71  },
72  {
73    id: 'cohesion',
74    title: 'Cohesion and coupling',
75    idea: 'A change that touches many files is a hint the boundaries are off: related logic should live together, and modules should depend on each other as little as possible.',
76    realWorld: 'Architects ask "which module owns this decision?" When the answer is "several", that is design debt.',
77    tryNext: 'Ask Claude which module should own the thing you just changed across many files, and whether to consolidate it.',
78  },
79  {
80    id: 'semver',
81    title: 'Semantic versioning and release notes',
82    idea: 'MAJOR.MINOR.PATCH = breaking.feature.fix. A changelog entry and a git tag per release make history auditable.',
83    realWorld: 'Release numbers are claimed when a release branch is cut, not at merge time, which avoids two branches taking the same number.',
84    tryNext: 'Tag releases (git tag v0.11.0) when they merge, so a deploy can always be traced to a commit.',
85  },
86  {
87    id: 'security',
88    title: 'Threat modelling in five minutes',
89    idea: 'For each feature: what are the assets, who could abuse it, what is the worst case, and what control stops it (STRIDE is a handy checklist).',
90    realWorld: 'Secrets live in a secret manager or env vars, tokens get the least privilege they need, and anything from outside is untrusted input.',
91    tryNext: 'Before the next feature with an API or user input, ask Claude for a STRIDE pass and the top 3 risks.',
92  },
93  {
94    id: 'requirements',
95    title: 'Requirements and acceptance criteria',
96    idea: 'Before building: who is it for, what problem it solves, and how you will know it worked.',
97    realWorld: 'Tickets carry acceptance criteria ("Given / When / Then"); a PR is done when they pass, not when the code compiles.',
98    tryNext: 'Start the next prompt with 2-3 acceptance criteria and ask Claude to check them off at the end.',
99  },
100  {
101    id: 'observability',
102    title: 'Observability before fixes',
103    idea: 'Logs, metrics and traces tell you what the system did. Reproduce a bug with a failing test before fixing it.',
104    realWorld: 'On-call engineers fix faster with structured logs and dashboards than with guesses, and a regression test keeps the bug fixed.',
105    tryNext: 'For the next bug, ask Claude to first write a failing test that reproduces it, then fix it.',
106  },
107]
108
109const TRENDS = [
110  'Plan mode for anything bigger than ~30 minutes: review the plan like a design doc before code exists.',
111  'Keep CLAUDE.md short and stable: it is sent with every request, and editing it mid-session breaks the prompt cache.',
112  'Send broad searches to an Explore subagent: the main conversation stays small, so every later turn is cheaper.',
113  'One feature per session and per PR, with a short handoff note; long sessions cost more per turn as context grows.',
114  'Parallel work belongs in separate git worktrees, one branch each, so sessions never edit the same checkout.',
115  'Lower effort for routine edits; save high or max effort for architecture and hard bugs.',
116  'Hooks turn "always do X" into guarantees: a guard you write once beats a reminder you repeat.',
117  'Read the diff before you merge: you own the code, not the model.',
118]
119
120const LINKS = [
121  { label: 'Claude Code docs', href: 'https://docs.claude.com/en/docs/claude-code/overview' },
122  { label: 'Claude Code changelog', href: 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md' },
123  { label: 'Anthropic engineering blog', href: 'https://www.anthropic.com/engineering' },
124]
125
126const fromUsage = (u: ModelUsage | undefined): CoachTokens =>
127  u ? { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens } : ZERO
128
129const plus = (a: CoachTokens, b: CoachTokens): CoachTokens => ({
130  input: a.input + b.input,
131  output: a.output + b.output,
132  cacheRead: a.cacheRead + b.cacheRead,
133  cacheWrite: a.cacheWrite + b.cacheWrite,
134})
135
136const totalIn = (t: CoachTokens) => t.input + t.cacheRead + t.cacheWrite
137const hitPct = (t: CoachTokens) => (totalIn(t) > 0 ? (t.cacheRead / totalIn(t)) * 100 : null)
138const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
139const usd = (n: number | null) => (n === null ? '—' : n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
140const fmt = (ms: number) => {
141  const s = Math.round(ms / 1000)
142  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
143}
144
145const projectOf = (dir: string) => {
146  const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
147  return main.split(/[\\/]/).filter(Boolean).pop() ?? main
148}
149
150let costAtStart: number | null = null
151let modelBefore: string | null = null
152
153const fresh = async ($: EngineInterface): Promise<CoachStats> => ({
154  project: projectOf(await $.session.cwd()),
155  model: await $.session.model(),
156  effort: null,
157  turns: 0,
158  main: ZERO,
159  last: null,
160  sub: { ...ZERO, runs: 0 },
161  contextTokens: null,
162  contextWindow: null,
163  costUsd: null,
164  files: [],
165  edits: 0,
166  editsSinceTest: 0,
167  testsRun: 0,
168  commits: 0,
169  commitsOnMain: 0,
170  prsOpened: 0,
171  branch: null,
172  planUsed: false,
173  perfRuns: 0,
174  tipsShown: {},
175})
176
177const change = async ($: EngineInterface, fn: (s: CoachStats) => CoachStats) => {
178  const base = (await read($, stats)) ?? (await fresh($))
179  await update($, stats, s => fn(s ?? base))
180}
181
182const ready = (s: CoachStats, id: string) => s.turns - (s.tipsShown[id] ?? -99) >= COOLDOWN_TURNS
183
184// At most one tip per turn, the most important rule that fires.
185const pickTip = (s: CoachStats, switched: boolean): { id: string; text: string } | null => {
186  const last = s.last
187  const hit = last ? hitPct(last) : null
188  const ctxPct = s.contextTokens !== null && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : null
189  const subShare = totalIn(s.sub) / Math.max(1, totalIn(s.sub) + totalIn(s.main))
190
191  if (s.commitsOnMain > (s.tipsShown['main-commits'] ?? 0)) {
192    return {
193      id: 'main-commits',
194      text: '🟥 A commit went straight to main. At work, main is protected: branch → PR → review + CI → merge. It also makes a bad change a one-click revert.',
195    }
196  }
197  if (last && s.turns > 1 && hit !== null && totalIn(last) >= 30_000 && hit < 50 && ready(s, 'cache')) {
198    return {
199      id: 'cache',
200      text: switched
201        ? `🟨 Cache hit ${Math.round(hit)}% this turn: expected right after a model switch (each model keeps its own cache). It recovers next turn.`
202        : `🟥 Cache hit only ${Math.round(hit)}% this turn. Cached input costs ~10% of fresh input; misses come from model switches, CLAUDE.md or memory edits mid-session, /compact, or a long idle gap (the cache expires).`,
203    }
204  }
205  if (ctxPct !== null && ctxPct >= 75 && ready(s, 'context')) {
206    return {
207      id: 'context',
208      text: `🟨 Context ${Math.round(ctxPct)}% full (${tok(s.contextTokens!)} of ${tok(s.contextWindow!)}). Every turn re-reads all of it: slower, pricier, and quality dips near the limit. Finish this step, then /compact with a focus or start a fresh session with a handoff.`,
209    }
210  }
211  if (s.editsSinceTest >= 5 && ready(s, 'untested')) {
212    return {
213      id: 'untested',
214      text: `🟨 ${s.editsSinceTest} edits since tests last ran. The pro habit: run the suite (or add a test) before calling a change done; "it looked right" is not evidence.`,
215    }
216  }
217  if (last && last.costUsd !== null && last.costUsd >= 2 && ready(s, 'cost')) {
218    return {
219      id: 'cost',
220      text: `💸 That turn cost about ${usd(last.costUsd)} at API prices (${tok(totalIn(last))} tokens read). Fresh sessions per feature, Explore subagents for searching, and lower effort for routine edits cut this.`,
221    }
222  }
223  if (last && last.ms >= 10 * 60_000 && !s.planUsed && ready(s, 'plan')) {
224    return {
225      id: 'plan',
226      text: `🟦 ${fmt(last.ms)} of building with no written plan. Architects change plans on paper, where it is cheap: try Plan mode (or an ADR) before the next big step.`,
227    }
228  }
229  if (s.sub.runs >= 3 && subShare >= 0.5 && ready(s, 'subagents')) {
230    return {
231      id: 'subagents',
232      text: `ℹ️ Subagents used ${Math.round(subShare * 100)}% of this session's tokens. Great for parallel or broad work; for a single lookup, a direct search is cheaper.`,
233    }
234  }
235  return null
236}
237
238// The single most useful next step, short enough for the dock's hover card.
239const topAction = (s: CoachStats): string => {
240  const ctxPct = s.contextTokens !== null && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : null
241  const hit = s.last ? hitPct(s.last) : null
242  if (s.commitsOnMain > 0) return 'Move this work to a branch and open a PR before merging'
243  if (ctxPct !== null && ctxPct >= 75) return `Context ${Math.round(ctxPct)}% full: compact or start a fresh session soon`
244  if (s.editsSinceTest >= 5) return `Run the tests: ${s.editsSinceTest} edits since the last run`
245  if (s.last && s.turns > 1 && hit !== null && hit < 50 && totalIn(s.last) >= 30_000) return 'Cache misses: avoid switching models or editing CLAUDE.md mid-session'
246  if (s.last && s.last.ms >= 10 * 60_000 && !s.planUsed) return 'Plan the next big step first (Plan mode)'
247  if (s.commits > 0 && s.prsOpened === 0) return 'Open a PR so the change gets reviewed'
248  return 'Nothing urgent: keep going'
249}
250
251const lessonFor = (s: CoachStats | null, offset: number) => {
252  const want =
253    !s ? 'requirements'
254    : s.commitsOnMain > 0 ? 'branching'
255    : s.prsOpened > 0 ? 'code-review'
256    : s.perfRuns > 0 ? 'perf-budget'
257    : s.files.some(f => /package\.json$/.test(f)) ? 'semver'
258    : s.files.some(f => /\.env|secret|token|auth/i.test(f)) ? 'security'
259    : s.files.length >= 10 ? 'cohesion'
260    : s.testsRun > 0 ? 'test-pyramid'
261    : s.planUsed ? 'adr'
262    : 'requirements'
263  const start = Math.max(0, LESSONS.findIndex(l => l.id === want))
264  return LESSONS[(start + offset) % LESSONS.length]!
265}
266
267const LEVEL_HEX = { success: '#4ade80', warning: '#facc15', error: '#f87171' } as const
268
269// A solid rounded bar (track, then fill) as SVG, for surfaces that draw it.
270const svgBar = (fraction: number, width: number, color: string) => {
271  const fill = Math.max(0, Math.min(width, Math.round(fraction * width)))
272  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
273    `<rect y="1.5" width="${width}" height="5" rx="2.5" fill="#3f3f46"/>` +
274    (fill > 0 ? `<rect y="1.5" width="${Math.max(fill, 5)}" height="5" rx="2.5" fill="${color}"/>` : '') +
275    '</svg>'
276}
277
278const tone = (pct: number | null, good: number, ok: number, higherIsBetter: boolean) =>
279  pct === null ? undefined
280  : higherIsBetter ? (pct >= good ? 'success' : pct >= ok ? 'warning' : 'error')
281  : pct <= good ? 'success' : pct <= ok ? 'warning' : 'error'
282
283
284const currentBranch = async ($: EngineInterface) => {
285  const run = await $.process.run(['git', 'branch', '--show-current']).catch(() => null)
286  return run && run.exitCode === 0 ? run.stdout.trim() || null : null
287}
288
289const togglePane = async ($: EngineInterface) => {
290  if ((await $.ui.panes()).some(p => p.id === PANE)) {
291    await $.ui.close({ id: PANE })
292    // A plugin's own close does not reach its own ui.close hook: reset the flag here.
293    await update($, isOpen, () => false)
294    return 'Coach closed.'
295  }
296  const opened = await $.ui.open({ id: PANE, title: 'Coach' })
297  if (opened.isPlaced) {
298    await update($, isOpen, () => true)
299    await update($, unseen, () => false)
300  } else {
301    $.ui.toast(`Coach could not open (${opened.reason}).`)
302  }
303  return opened.isPlaced ? 'Coach opened.' : `Could not open the pane (${opened.reason}).`
304}
305
306export const register: Register = on => {
307  on('session.start', async ($, e, next) => {
308    await $.command.register({ name: 'coach', description: 'Coach: cost, cache, context, habits and an architect lesson for this session' })
309    if ((await read($, stats)) === null) {
310      const s = await fresh($)
311      await update($, stats, cur => cur ?? s)
312    }
313    return next(e)
314  })
315
316  on('classic.UserPromptSubmit', async ($, e, next) => {
317    if (e.permission_mode === 'plan') await change($, s => ({ ...s, planUsed: true }))
318    return next(e)
319  })
320
321  on('classic.PostToolUse', async ($, e, next) => {
322    const level = e.effort?.level
323    if (e.agent_id === undefined && level) {
324      const s = await read($, stats)
325      if (s && s.effort !== level) await change($, cur => ({ ...cur, effort: level }))
326    }
327    return next(e)
328  })
329
330  on('turn.start', async ($, e, next) => {
331    costAtStart = (await $.session.usage()).cost?.usd ?? null
332    modelBefore = (await read($, stats))?.last?.model ?? null
333    return next(e)
334  })
335
336  on(
337    'tool.call',
338    { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell', 'ExitPlanMode', 'mcp__mission-control__plan'] },
339    async ($, e, next) => {
340      const ran = await next(e)
341      if (ran.deny !== undefined || ran.isError === true) return ran
342      if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
343        const path = (e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path).replace(/\\/g, '/')
344        const short = path.split('/').slice(-3).join('/')
345        await change($, s => ({
346          ...s,
347          edits: s.edits + 1,
348          editsSinceTest: s.editsSinceTest + 1,
349          files: s.files.includes(short) ? s.files : [...s.files, short].slice(-300),
350        }))
351      } else if (e.tool === 'Bash' || e.tool === 'PowerShell') {
352        const cmd = e.command
353        const isTest = TEST_RE.test(cmd)
354        const isCommit = COMMIT_RE.test(cmd)
355        const branch = isCommit ? await currentBranch($) : null
356        await change($, s => ({
357          ...s,
358          testsRun: s.testsRun + (isTest ? 1 : 0),
359          editsSinceTest: isTest ? 0 : s.editsSinceTest,
360          commits: s.commits + (isCommit ? 1 : 0),
361          commitsOnMain: s.commitsOnMain + (isCommit && (branch === 'main' || branch === 'master') ? 1 : 0),
362          branch: branch ?? s.branch,
363          prsOpened: s.prsOpened + (PR_RE.test(cmd) ? 1 : 0),
364          perfRuns: s.perfRuns + (PERF_RE.test(cmd) ? 1 : 0),
365        }))
366      } else {
367        await change($, s => ({ ...s, planUsed: true }))
368      }
369      return ran
370    },
371  )
372
373  on('turn.complete', async ($, e, next) => {
374    const done = await next(e)
375    const u = fromUsage(e.usage)
376    if (e.agentId !== undefined) {
377      await change($, s => ({ ...s, sub: { ...plus(s.sub, u), runs: s.sub.runs + 1 } }))
378      return done
379    }
380    const usage = await $.session.usage()
381    const cost = usage.cost?.usd ?? null
382    const turnCost = cost !== null && costAtStart !== null ? Math.max(0, cost - costAtStart) : null
383    const model = e.usage?.model ?? (await $.session.model())
384    await change($, s => ({
385      ...s,
386      model,
387      turns: s.turns + 1,
388      main: plus(s.main, u),
389      last: { ...u, ms: e.durationMs, costUsd: turnCost, model },
390      contextTokens: usage.context.tokens ?? s.contextTokens,
391      contextWindow: usage.context.window,
392      costUsd: cost,
393    }))
394    const latest = await read($, stats)
395    if (latest) {
396      const action = topAction(latest)
397      await update($, lastTip, () => action)
398    }
399    if (e.reason !== 'answer') return done
400
401    const s = await read($, stats)
402    const tip = s ? pickTip(s, modelBefore !== null && modelBefore !== model) : null
403    if (s && tip) {
404      await change($, cur => ({
405        ...cur,
406        tipsShown: { ...cur.tipsShown, [tip.id]: tip.id === 'main-commits' ? cur.commitsOnMain : cur.turns },
407      }))
408      if (!(await read($, isOpen))) await update($, unseen, () => true)
409      void $.session
410        .append({ message: { type: 'system', content: [{ type: 'text', text: `🎓 Coach · ${tip.text}` }] } })
411        .catch(() => $.ui.log(`🎓 Coach · ${tip.text}`))
412    }
413    return done
414  })
415
416  on('command.run', { command: 'coach' }, async $ => ({ text: await togglePane($) }))
417
418  // A press on the dock's Coach button.
419  on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
420    const done = await next(e)
421    // Only a write that landed: update() retries a missed one, which would toggle twice.
422    const sig = e.value as { target?: string; action?: string } | null
423    if (done.value?.isSet !== true || !sig) return done
424    if (sig.target === 'coach') await togglePane($)
425    // Review: show the panel, never close it.
426    else if (sig.action === 'review' && !(await $.ui.panes()).some(p => p.id === PANE)) await togglePane($)
427    return done
428  })
429
430  on('ui.close', { id: PANE }, async ($, e, next) => {
431    const done = await next(e)
432    await update($, isOpen, () => false)
433    return done
434  })
435
436  // One glance: three colored metric rows, one habits row, one "Do next".
437  // Everything else (tokens, subagents, lesson, pro tip, links) sits behind More.
438  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
439    const elements = $.ui.resolve(e)
440    const { Box, Text, Button, Link } = elements
441    const SvgEl = 'Svg' in elements && e.surface !== 'terminal' ? elements.Svg : undefined
442    const s = await read($, stats)
443    const isMoreOpen = await read($, isMore)
444    const offset = await read($, lesson)
445    const l = lessonFor(s, offset)
446    const trend = TRENDS[(await read($, tipIndex)) % TRENDS.length]!
447
448    const hit = s?.last ? hitPct(s.last) : null
449    const ctxPct = s && s.contextTokens !== null && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : null
450    const subShare = s ? totalIn(s.sub) / Math.max(1, totalIn(s.sub) + totalIn(s.main)) : 0
451    const perTurn = s && s.turns > 0 && s.costUsd !== null ? s.costUsd / s.turns : null
452
453    const metric = (key: string, label: string, value: string, level: string | undefined, fill: number | null, verdict: string) => (
454      <Box key={`m:${key}`} flexDirection="row" gap={1}>
455        <Text color={level}>●</Text>
456        <Box width={8}>
457          <Text bold>{label}</Text>
458        </Box>
459        <Box width={6}>
460          <Text bold color={level}>
461            {value}
462          </Text>
463        </Box>
464        {fill !== null && SvgEl && (
465          <SvgEl source={svgBar(fill / 100, 84, LEVEL_HEX[level as keyof typeof LEVEL_HEX] ?? '#9ca3af')} alt={`${Math.round(fill)}%`} width={84} height={8} />
466        )}
467        {fill !== null && !SvgEl && (
468          <Box flexDirection="row">
469            <Text color={level}>{'━'.repeat(Math.round((fill / 100) * 12))}</Text>
470            <Text color="#3f3f46">{'━'.repeat(12 - Math.round((fill / 100) * 12))}</Text>
471          </Box>
472        )}
473        <Text dimColor wrap="truncate-end">
474          {verdict}
475        </Text>
476      </Box>
477    )
478    const habit = (label: string, ok: boolean | null) => (
479      <Text color={ok === null ? undefined : ok ? 'success' : 'error'} dimColor={ok === null}>
480        {ok === null ? '·' : ok ? '✓' : '✗'} {label}
481      </Text>
482    )
483
484    return (
485      <Box flexDirection="column" gap={1}>
486        <Box flexDirection="row" gap={1}>
487          <Text bold>Coach</Text>
488          <Text dimColor>{s ? `${s.project} · ${s.turns} turns` : 'after the first reply'}</Text>
489          <Box flexGrow={1} />
490          <Button key="more" label={isMoreOpen ? 'Less' : 'More'} plain onPress={() => update($, isMore, v => !v)} />
491          <Button key="close" label="✕" plain onPress={() => togglePane($)} />
492        </Box>
493
494        <Box flexDirection="column">
495          {metric(
496            'cache',
497            'Cache',
498            hit === null ? '—' : `${Math.round(hit)}%`,
499            tone(hit, 80, 50, true),
500            hit,
501            hit === null ? 'shows after the first reply' : hit >= 80 ? 'reusing context well' : hit >= 50 ? 'partly re-read' : 'mostly re-read (costly)',
502          )}
503          {metric(
504            'context',
505            'Context',
506            ctxPct === null ? '—' : `${Math.round(ctxPct)}%`,
507            tone(ctxPct, 50, 75, false),
508            ctxPct,
509            ctxPct === null ? '' : ctxPct <= 50 ? 'room to spare' : ctxPct <= 75 ? 'getting full' : 'near the limit: compact soon',
510          )}
511          {metric('cost', 'Cost', usd(s?.costUsd ?? null), perTurn !== null && perTurn >= 1 ? 'warning' : undefined, null, perTurn === null ? '' : `${usd(perTurn)} per turn`)}
512        </Box>
513
514        <Box flexDirection="row" gap={2}>
515          <Box width={8}>
516            <Text bold>Habits</Text>
517          </Box>
518          {habit('Plan', s ? s.planUsed || null : null)}
519          {habit('Branch', s && s.commits > 0 ? s.commitsOnMain === 0 : null)}
520          {habit('Tests', s && s.edits > 0 ? s.testsRun > 0 && s.editsSinceTest < 5 : null)}
521          {habit('Commits', s && s.edits > 0 ? s.commits > 0 : null)}
522          {habit('PR', s && s.commits > 0 ? s.prsOpened > 0 : null)}
523        </Box>
524
525        <Box flexDirection="row" gap={1}>
526          <Box width={8}>
527            <Text bold color="suggestion">
528              Do next
529            </Text>
530          </Box>
531          <Text wrap="wrap">{s ? topAction(s) : 'Nothing yet'}</Text>
532        </Box>
533
534        <Box flexDirection="column">
535          {s && (
536            <Box flexDirection="row" gap={1}>
537              <Box width={8}>
538                <Text bold>Tokens</Text>
539              </Box>
540              <Text>{tok(s.main.input)} fresh</Text>
541              <Text color="success">{tok(s.main.cacheRead)} cached</Text>
542              <Text color="warning">{tok(s.main.cacheWrite)} cache-write</Text>
543              <Text color="#22d3ee">{tok(s.main.output)} out</Text>
544            </Box>
545          )}
546          {s && (
547            <Box flexDirection="row" gap={1}>
548              <Box width={8}>
549                <Text bold>Model</Text>
550              </Box>
551              <Text color="#a78bfa">{s.model}</Text>
552              {s.effort && <Text dimColor>effort {s.effort}</Text>}
553              {s.sub.runs > 0 && (
554                <Text color={subShare >= 0.5 ? 'warning' : undefined} dimColor={subShare < 0.5}>
555                  subagents {s.sub.runs} runs · {Math.round(subShare * 100)}% of tokens
556                </Text>
557              )}
558            </Box>
559          )}
560          <Box flexDirection="row" gap={1}>
561            <Box width={8}>
562              <Text bold color="claude">
563                Lesson
564              </Text>
565            </Box>
566            <Text bold>{l.title}</Text>
567          </Box>
568          <Box flexDirection="row" gap={1}>
569            <Box width={8}>
570              <Text> </Text>
571            </Box>
572            <Text dimColor wrap="wrap">
573              {l.idea}
574            </Text>
575          </Box>
576        </Box>
577
578        {isMoreOpen && (
579          <Box flexDirection="column" gap={1}>
580            <Box flexDirection="column">
581              <Box flexDirection="row" gap={1}>
582                <Text color="suggestion">Try: {l.tryNext}</Text>
583              </Box>
584              <Text dimColor>At work: {l.realWorld}</Text>
585              <Button key="another" label="Another lesson" plain onPress={() => update($, lesson, n => n + 1)} />
586            </Box>
587            <Box flexDirection="column">
588              <Box flexDirection="row" gap={1}>
589                <Text bold color="claude">
590                  Pro tip
591                </Text>
592                <Button key="tip" label="Next tip" plain onPress={() => update($, tipIndex, n => n + 1)} />
593              </Box>
594              <Text>{trend}</Text>
595              <Box flexDirection="row" gap={2}>
596                {LINKS.map(link => (
597                  <Link href={link.href} label={link.label} />
598                ))}
599              </Box>
600            </Box>
601          </Box>
602        )}
603      </Box>
604    )
605  })
606}
607
types/index.d.ts 32 lines
1export type CoachTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
2
3export type CoachStats = {
4  project: string
5  model: string
6  effort: string | null
7  turns: number
8  main: CoachTokens
9  last: (CoachTokens & { ms: number; costUsd: number | null; model: string }) | null
10  sub: CoachTokens & { runs: number }
11  contextTokens: number | null
12  contextWindow: number | null
13  costUsd: number | null
14  files: string[]
15  edits: number
16  editsSinceTest: number
17  testsRun: number
18  commits: number
19  commitsOnMain: number
20  prsOpened: number
21  branch: string | null
22  planUsed: boolean
23  perfRuns: number
24  tipsShown: { [id: string]: number }
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    coach: { stats: CoachStats | null; lesson: number; tip: number; unseen: boolean; isOpen: boolean; lastTip: string | null; isMore: boolean }
30  }
31}
32