SLOPSHOPPER

session-deck

A pane beside the chat with the model in use, context fill, plan usage limits and their forecast, plan progress, subagents and scheduled tasks

newpaneguardcommandtoaststatus
v0.8.1MITupdated 2026-10-25mehboobali98/session-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-deck
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for the first reply │ ┃ ⏺ Read(src/auth.ts) │ ┃ Context ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃━━━━━━━━━━━━━━━ ⎿ Added 2 lines, removed 1 line │ ┃ ━━━━━━━━━━━━ ⏺ Bash(bun test) │ ┃ 97k of 200k tokens handoff at 50% ⎿ 3 pass, 1 fail │ ┃ │ ┃ Limits 5h ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ────────────────────────────────────────── │ ┃ 5h ✻ Worked for 42s · done 4:20 PM │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ 31% › /deck │ ┃ ⎿ session-deck: Session deck opened. │ ┃ Plan │ ┃ ──────────────────────────────────────────── │ ┃ ── │ ┃ Reading a docs/superpowers/plans file starts │ ┃ it │ ┃ │ ┃ Agents │ ┃ ──────────────────────────────────────────── │ ┃ │ ┃ Scheduled non │ ┃ ───────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-deck: ctx 49% · 5h 31%

Draws

Pane · Session
Waiting for the first reply Context ──────────────────────────────────────────── 49% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃━━━━━━━━━━━━━━━━━━━━━━━━━━━ 97k of 200k tokens handoff at 50% Limits ────────────────────────────────────────── 5h 31% 5h ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 31% ↻ Plan ────────────────────────────────────────────── none Reading a docs/superpowers/plans file starts it Agents ──────────────────────────────────────────── none Scheduled ───────────────────────────────────────── none Routines ────────────────────────────────────── checking
README

session-deck

CI

A Claude Code mod that keeps a pane beside the chat with the things I kept stopping to check mid-session: which model is answering, how full the context is, how much of my plan's usage limits are left and whether they will last, where I am in the implementation plan, what the subagents are doing, and what is scheduled to run.

What it shows

  • Model: the model and effort the last request of the main session ran on.
  • Checklist, when you set one: the things you want done at the start of every session, ticked off as their tools run. The first prompt of a session tells Claude which are still open, so they come before anything else. Once all are done the section folds to its heading.
  • Context: the live window's fill and tokens used, as a gauge with a tick at the 50% handoff mark. Under it, the three categories that fill it most (from the same local estimate /context makes, so it costs nothing) and the three largest tool results of the session, until a compaction clears them. A toast fires at 40% and again at 50%. At 50% the mod also adds one note to your next prompt, telling Claude to say so, finish the step in hand and prepare the handoff.
  • Limits: the 5-hour and weekly windows, with the time until each one resets. A toast fires at 80% and 95%. When a window is on pace to run out before it resets, a red dashed trail runs to the end of its gauge, a line under it says when, worked out from the use since the window opened, and a toast says so once per window. The forecast waits until a fifth of the window has passed.
  • Repo, inside a git repository: the branch, how many files changed and how far it is ahead of or behind its upstream, refreshed after every turn, and the branch's pull request with how its checks stand, asked of gh at most every two minutes. Git runs without taking the index lock, so it never collides with your own git commands.
  • Plan: the tasks of the last docs/superpowers/plans/*.md file Claude read or wrote, taken from its ### Task N: headings and marked done, in progress or to do. Claude marks them through the mod's plan_progress tool, so the plan file is never edited. Progress is stored per plan, so a later session that opens the same plan picks up where the last one stopped.
  • Follow-ups, when you set a file: the bullets under one heading of a markdown file, re-read at the end of every turn. A bullet that names a date (26 Oct, Oct 26, 2026-10-26) shows when it is due, and the due and late ones come first in the warning colour.
  • Agents: this session's subagents, running ones first: the agent type, the model and effort it actually ran on, its task, and while it runs, the tool it is on. A subagent on Opus or Fable is drawn in the warning color. The last four finished ones stay, with how long ago they finished.
  • Scheduled here: this session's crons, /loop wakeups and background jobs, as of the end of the last turn.
  • Routines: your cloud routines, with the time to each one's next run and how its last run went. Refreshed every 30 minutes and whenever Claude lists them.

Each section's heading carries its headline figure, so the headings alone tell you where things stand, and an empty section is just its heading. A status line under the prompt carries the short version: ctx 31% · 5h 52% · 7d 28%.

Install

At the prompt of a terminal session:

/plugin install session-deck --marketplace mehboobali98/session-deck

From a shell:

claude plugin install session-deck --marketplace mehboobali98/session-deck

Answer y to add the marketplace, then pick a scope.

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with mehboobali98/session-deck, then Add plugin.

Use

The pane opens by itself on a terminal 144 columns wide or more. /deck opens it at any width and refreshes the routine list.

Settings

Each shows up as a row in /config, and a change there applies at once.

SettingDefaultWhat it does
Handoff mark (handoffAt)50The context percent the deck marks for a handoff. A toast fires when context reaches it, and a warning 10 points earlier.
Status line (statusLine)fullWhat the deck pins under the prompt: full for context and limits, limits if your own status line already shows context, or off.
Follow-ups (followUps)emptyA markdown file and heading as path#Heading, such as ~/notes/status.md#Promised follow-ups.
Session checklist (checklist)emptyItems as Label=Tool or Label=Tool:path part, split by semicolons. Inbox=mcp__claude_ai_Gmail__search_threads; Status=Read:status/job-search.md ticks Inbox on any inbox search and Status on a Read of a path containing status/job-search.md.

/config keeps them in your settings.json under pluginConfigs.

Notes

  • The routine list comes from the RemoteTrigger tool, so it needs a claude.ai sign-in. The mod allows its own read-only list call so the 30-minute refresh never raises a permission prompt. Every other RemoteTrigger call goes through your normal permissions.
  • Limits show only on a Claude subscription, after the first reply of a session.
  • The pull request line needs the GitHub CLI (gh) signed in; without it the Repo section shows the branch alone.
  • Mods are built on Claude Code's function hooks, an early-access API, so a later release may need changes here.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

CI runs the first two on every push and pull request, on the Claude Code version the mod was last checked against (2.1.310). Bump it in .github/workflows/ci.yml after checking a newer release.

License

MIT

Source 2 files
hooks/register.tsx 1096 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  PluginOptions,
5  Register,
6  SessionContextUsage,
7  SessionRateLimit,
8  TurnStepInput,
9} from 'claude-code'
10
11import type { Agent, Checks, Effort, FollowUp, Limit, Part, Plan, PullRequest, Repo, Routine, TaskStatus, Usage } from '../types'
12
13const PANE = 'session-deck'
14const TITLE = 'Session'
15const TOOL = 'mcp__session-deck__plan_progress'
16const PLAN_FILE = /\/docs\/superpowers\/plans\/[^/]+\.md$/
17const TASK_HEADING = /^#{2,4}[ \t]+Task[ \t]+(\d+)[ \t]*[:.)–-]?[ \t]*(.*)$/gm
18const LIMIT_WARN_AT = 80
19const LIMIT_ALARM_AT = 95
20const ROUTINE_REFRESH_MS = 30 * 60_000
21const PLAN_ROWS = 8
22const AGENTS_KEPT = 4
23const HOUR_MS = 3_600_000
24const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR_MS, seven_day: 7 * 24 * HOUR_MS }
25const COSTLY_MODEL = /opus|fable/i
26const ARG_KEYS = ['description', 'pattern', 'file_path', 'command', 'query', 'url'] as const
27const DAY_MS = 24 * HOUR_MS
28const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
29const FOLLOW_UP_ROWS = 6
30const FILL_PARTS = 3
31const RESULT_MIN_TOKENS = 1_000
32const PR_REFRESH_MS = 2 * 60_000
33const FAILED_CHECKS = ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
34const ACCENT = 'suggestion'
35const TRACK = 'subtle'
36
37const PLAN_TOOL_DESCRIPTION =
38  "Marks a task of the implementation plan in the person's session deck pane as todo, doing or done. " +
39  'The deck loads a plan when you read or write a docs/superpowers/plans/*.md file. ' +
40  'While executing such a plan, call this with status doing when you start a task and done once its verification passes. ' +
41  "task is the number in the plan's '### Task N:' heading."
42
43const usage = atom({ plugin: 'session-deck', key: 'usage' } as const, null, { shape: 'usage-1' })
44const routines = atom({ plugin: 'session-deck', key: 'routines' } as const, null, {
45  shape: 'routines-1',
46})
47const wakeups = atom(
48  { plugin: 'session-deck', key: 'wakeups' } as const,
49  { crons: [], jobs: [] },
50  { shape: 'wakeups-1' },
51)
52const plan = atom({ plugin: 'session-deck', key: 'plan' } as const, null, { shape: 'plan-1' })
53const model = atom({ plugin: 'session-deck', key: 'model' } as const, null, { shape: 'model-1' })
54const agents = atom({ plugin: 'session-deck', key: 'agents' } as const, [], { shape: 'agents-1' })
55const nudge = atom({ plugin: 'session-deck', key: 'nudge' } as const, null, { shape: 'nudge-1' })
56const checklist = atom(
57  { plugin: 'session-deck', key: 'checklist' } as const,
58  { ticked: [], isBriefed: false },
59  { shape: 'checklist-1' },
60)
61const followUps = atom({ plugin: 'session-deck', key: 'followUps' } as const, null, { shape: 'follow-ups-1' })
62const repo = atom({ plugin: 'session-deck', key: 'repo' } as const, null, { shape: 'repo-1' })
63const fill = atom({ plugin: 'session-deck', key: 'fill' } as const, { parts: [], results: [] }, { shape: 'fill-1' })
64const clock = atom({ plugin: 'session-deck', key: 'now' } as const, 0)
65
66const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
67
68const label = (kind: string) => LIMIT_LABELS[kind] ?? kind
69
70const isStatus = (value: unknown): value is TaskStatus =>
71  value === 'todo' || value === 'doing' || value === 'done'
72
73const storeKey = (path: string) => `plan:${path}`
74
75type StatusMode = 'full' | 'limits' | 'off'
76
77type Check = { label: string; tool: string; match?: string }
78
79type Source = { path: string; heading: string }
80
81type Settings = {
82  handoffAt: number
83  warnAt: number
84  statusLine: StatusMode
85  checks: Check[]
86  followUps?: Source
87}
88
89/**
90 * @return [Source, undefined] the file and heading of a `path#Heading` setting, or undefined
91 *   when it names no both
92 */
93function sourceFrom(text: unknown): Source | undefined {
94  const [path, ...heading] = String(text ?? '').split('#')
95
96  return path?.trim() && heading.join('#').trim()
97    ? { path: path.trim(), heading: heading.join('#').trim() }
98    : undefined
99}
100
101/**
102 * @return [Array<Check>] the items of a `Label=Tool` or `Label=Tool:path part` list split on
103 *   semicolons, skipping any that do not read that way
104 */
105function checksFrom(text: unknown): Check[] {
106  return String(text ?? '')
107    .split(/[;\n]/)
108    .map(part => /^\s*([^=]+?)\s*=\s*([^\s:]+)\s*(?::\s*(.+?))?\s*$/.exec(part))
109    .flatMap(found => {
110      const [, label, tool, match] = found ?? []
111      if (label === undefined || tool === undefined) return []
112
113      return [match === undefined ? { label, tool } : { label, tool, match }]
114    })
115}
116
117/**
118 * @return [Settings] the manifest's userConfig values, with the early warning 10 points
119 *   before the handoff mark
120 */
121function settingsFrom(options: PluginOptions): Settings {
122  const handoffAt = Math.min(95, Math.max(10, Math.round(Number(options.handoffAt) || 50)))
123  const statusLine = options.statusLine === 'limits' || options.statusLine === 'off' ? options.statusLine : 'full'
124
125  const followUpsSource = sourceFrom(options.followUps)
126
127  return {
128    handoffAt,
129    warnAt: handoffAt - 10,
130    statusLine,
131    checks: checksFrom(options.checklist),
132    ...(followUpsSource === undefined ? {} : { followUps: followUpsSource }),
133  }
134}
135
136const toUsage = (context: SessionContextUsage, limits: readonly SessionRateLimit[]): Usage => ({
137  percent: context.percent,
138  tokens: context.tokens,
139  window: context.window,
140  limits: limits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
141})
142
143function statusText(u: Usage, mode: StatusMode): string | undefined {
144  if (mode === 'off') return undefined
145
146  const parts = [
147    ...(mode === 'full' && u.percent !== undefined ? [`ctx ${u.percent}%`] : []),
148    ...u.limits.map(l => `${label(l.kind)} ${Math.round(l.percentUsed)}%`),
149  ]
150
151  return parts.length > 0 ? parts.join(' · ') : undefined
152}
153
154/**
155 * @return [number, undefined] the highest mark `after` reached that `before` was under
156 */
157function crossed(before: number | undefined, after: number | undefined, marks: number[]) {
158  return marks.filter(mark => (before ?? 0) < mark && (after ?? 0) >= mark).pop()
159}
160
161const reachedHandoff = (before: Usage | null, after: Usage, { warnAt, handoffAt }: Settings) =>
162  crossed(before?.percent, after.percent, [warnAt, handoffAt]) === handoffAt
163
164const handoffNote = (percent: number | undefined, handoffAt: number) =>
165  `session-deck: context is at ${percent ?? handoffAt}%, past the ${handoffAt}% handoff mark. ` +
166  'Tell the person, finish the step in hand, then prepare the handoff before starting anything new.'
167
168const plain = (line: string) =>
169  line
170    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
171    .replace(/[*_`]/g, '')
172    .replace(/\s+/g, ' ')
173    .trim()
174
175function dayOf(day: number, month: string, at: number): string | undefined {
176  const index = MONTHS.indexOf(month.toLowerCase())
177  if (index < 0 || day < 1 || day > 31) return undefined
178
179  const year = new Date(at).getUTCFullYear()
180  const [nearest] = [year - 1, year, year + 1]
181    .map(y => Date.UTC(y, index, day))
182    .sort((a, b) => Math.abs(a - at) - Math.abs(b - at))
183
184  return nearest === undefined ? undefined : new Date(nearest).toISOString().slice(0, 10)
185}
186
187/**
188 * @return [String, undefined] the first date a line names (`26 Oct`, `Oct 26`, `2026-10-26`) as
189 *   `YYYY-MM-DD`, a date without a year taken in the year that puts it nearest `at`
190 */
191function dueDate(text: string, at: number): string | undefined {
192  const found = [
193    ...[...text.matchAll(/(?<![\w/-])(\d{4})-(\d{2})-(\d{2})(?![\w-])/g)].map(m => ({ index: m.index, iso: `${m[1]}-${m[2]}-${m[3]}` })),
194    ...[...text.matchAll(/\b(\d{1,2})\s+([a-z]{3})[a-z]*\b/gi)].map(m => ({ index: m.index, iso: dayOf(Number(m[1]), m[2] ?? '', at) })),
195    ...[...text.matchAll(/\b([a-z]{3})[a-z]*\s+(\d{1,2})\b/gi)].map(m => ({ index: m.index, iso: dayOf(Number(m[2]), m[1] ?? '', at) })),
196  ]
197
198  return found.filter(f => f.iso !== undefined).sort((a, b) => a.index - b.index)[0]?.iso
199}
200
201/**
202 * @return [Array<FollowUp>, undefined] the top-level bullets under `heading`, or undefined when
203 *   the file has no such heading
204 */
205function followUpItems(text: string, heading: string, at: number): FollowUp[] | undefined {
206  const lines = text.split('\n')
207  const start = lines.findIndex(l => /^#{1,6}\s/.test(l) && plain(l.replace(/^#+/, '')).toLowerCase() === heading.toLowerCase())
208  if (start < 0) return undefined
209
210  const level = /^#+/.exec(lines[start] ?? '')?.[0].length ?? 1
211  const items: FollowUp[] = []
212
213  for (const line of lines.slice(start + 1)) {
214    const next = /^(#{1,6})\s/.exec(line)
215    if (next !== null && (next[1]?.length ?? 0) <= level) break
216
217    const bullet = /^[-*+]\s+(.*)$/.exec(line)?.[1]
218    if (bullet === undefined) continue
219
220    const item = plain(bullet)
221    const due = dueDate(item, at)
222    items.push(due === undefined ? { text: item } : { text: item, due })
223  }
224
225  return items
226}
227
228function dueIn(due: string, at: number): { label: string; isDue: boolean } {
229  const days = Math.round((Date.parse(due) - Date.parse(new Date(at).toISOString().slice(0, 10))) / DAY_MS)
230
231  if (days < -1) return { label: `${-days}d late`, isDue: true }
232  if (days === -1) return { label: 'yesterday', isDue: true }
233  if (days === 0) return { label: 'today', isDue: true }
234
235  return { label: days === 1 ? 'tomorrow' : `in ${days}d`, isDue: false }
236}
237
238async function refreshFollowUps($: EngineInterface, source: Source | undefined): Promise<void> {
239  if (source === undefined) return
240
241  const path = source.path.startsWith('~/') ? `${(await $.env.get('HOME')) ?? ''}${source.path.slice(1)}` : source.path
242  const at = await $.clock.now()
243  const text = await $.fs.read(path).catch(() => undefined)
244  const items = text === undefined ? undefined : followUpItems(text, source.heading, at)
245
246  await update($, followUps, () =>
247    items !== undefined
248      ? { items }
249      : { items: [], error: text === undefined ? `Can't read ${source.path}` : `No "${source.heading}" heading in ${source.path}` },
250  )
251}
252
253const meets = ({ tool, match }: Check, called: string, input: Record<string, unknown>) =>
254  tool === called && (match === undefined || Object.values(input).some(v => typeof v === 'string' && v.includes(match)))
255
256const checklistNote = (open: readonly Check[]) =>
257  "session-deck: this person's checklist for the start of every session, before any other work: " +
258  open.map(c => `${c.label} (${c.match === undefined ? c.tool : `${c.tool} on a path containing ${c.match}`})`).join(', ') +
259  '. Do these first, then go on to what they asked.'
260
261/**
262 * @return [Array<String>] the open checklist items for Claude, on the first prompt of a session only
263 */
264async function briefing($: EngineInterface, checks: readonly Check[]): Promise<string[]> {
265  if (checks.length === 0) return []
266
267  const held = await read($, checklist)
268  if (held.isBriefed) return []
269
270  await update($, checklist, last => ({ ...last, isBriefed: true }))
271  const open = checks.filter(c => !held.ticked.includes(c.label))
272
273  return open.length === 0 ? [] : [checklistNote(open)]
274}
275
276/**
277 * @return [Array<String>] the handoff note for Claude, once the context crossed the mark
278 */
279async function handoffNotes($: EngineInterface, handoffAt: number): Promise<string[]> {
280  if ((await read($, nudge)) !== 'due') return []
281
282  const percent = (await read($, usage))?.percent
283  await update($, nudge, () => 'sent')
284
285  return [handoffNote(percent, handoffAt)]
286}
287
288function alerts(before: Usage | null, after: Usage, at: number, { warnAt, handoffAt }: Settings): string[] {
289  const lines: string[] = []
290  const mark = crossed(before?.percent, after.percent, [warnAt, handoffAt])
291
292  if (mark === handoffAt) {
293    lines.push(`Context at ${after.percent}%: write the handoff before starting anything new`)
294  } else if (mark !== undefined) {
295    lines.push(`Context at ${after.percent}%: aim for a clean stopping point by ${handoffAt}%`)
296  }
297
298  for (const limit of after.limits) {
299    const was = before?.limits.find(l => l.kind === limit.kind)?.percentUsed
300
301    if (crossed(was, limit.percentUsed, [LIMIT_WARN_AT, LIMIT_ALARM_AT]) !== undefined) {
302      const reset = limit.resetsAt ? `, resets ${relative(limit.resetsAt, at)}` : ''
303      lines.push(`${label(limit.kind)} limit at ${limit.percentUsed}%${reset}`)
304    }
305  }
306
307  return lines
308}
309
310function span(ms: number): string {
311  const minutes = Math.round(ms / 60_000)
312  if (minutes < 60) return `${minutes}m`
313
314  const hours = Math.floor(minutes / 60)
315  if (hours < 24) return `${hours}h ${minutes % 60}m`
316
317  return `${Math.floor(hours / 24)}d ${hours % 24}h`
318}
319
320function relative(iso: string, at: number): string {
321  const ms = Date.parse(iso) - at
322  if (Number.isNaN(ms)) return ''
323
324  return ms >= 0 ? `in ${span(ms)}` : `${span(-ms)} ago`
325}
326
327/**
328 * @return [String, undefined] when the limit runs out at its pace since the window opened, or
329 *   undefined when it lasts to its reset or the window is too young to tell
330 */
331function pace(limit: Limit, at: number): string | undefined {
332  const length = WINDOW_MS[limit.kind]
333  const resetsAt = Date.parse(limit.resetsAt ?? '')
334  const used = limit.percentUsed
335  if (length === undefined || Number.isNaN(resetsAt) || used <= 0 || used >= 100) return undefined
336
337  const elapsed = at - (resetsAt - length)
338  if (elapsed < length / 5) return undefined
339
340  const outAt = at + ((100 - used) / used) * elapsed
341
342  return outAt < resetsAt
343    ? `runs out in ~${span(outAt - at)} at this pace, ${span(resetsAt - outAt)} before reset`
344    : undefined
345}
346
347/**
348 * @return [Array<String>] a toast for each limit newly on pace to run out before its reset,
349 *   once per window across sessions
350 */
351async function paceAlerts($: EngineInterface, limits: readonly Limit[], at: number): Promise<string[]> {
352  const lines: string[] = []
353
354  for (const limit of limits) {
355    const text = pace(limit, at)
356    const key = `paced:${limit.kind}`
357    if (text === undefined || (await $.store.get(key)) === limit.resetsAt) continue
358
359    await $.store.set(key, limit.resetsAt)
360    lines.push(`${label(limit.kind)} limit ${text}`)
361  }
362
363  return lines
364}
365
366/**
367 * @return [String] a model id as people say it: `claude-opus-5-5[1m]` is "Opus 5.5 (1M)"
368 */
369function modelName(id: string): string {
370  const match = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
371  const family = match?.[1]
372  if (family === undefined) return id
373
374  const version = match?.[3] === undefined ? match?.[2] : `${match[2]}.${match[3]}`
375  const name = `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${version ?? ''}`.trim()
376
377  return /\[1m\]/i.test(id) ? `${name} (1M)` : name
378}
379
380const modelFamily = (id: string) => /opus|sonnet|haiku|fable/i.exec(id)?.[0]?.toLowerCase() ?? id
381
382const runsOn = (id: string, effort: Effort | undefined) =>
383  effort === undefined ? modelFamily(id) : `${modelFamily(id)} ${effort}`
384
385/**
386 * @return [String] the tool and the argument that says most about the call, `Read register.tsx`
387 */
388function doing(tool: string, input: Record<string, unknown>): string {
389  const key = ARG_KEYS.find(k => typeof input[k] === 'string' && (input[k] as string).trim() !== '')
390  if (key === undefined) return tool
391
392  const arg = String(input[key]).replace(/\s+/g, ' ').trim()
393
394  return `${tool} ${key === 'file_path' ? (arg.split('/').pop() ?? arg) : arg}`
395}
396
397/**
398 * @return [Array<Agent>] the running agents, then the newest finished ones
399 */
400function kept(list: readonly Agent[]): Agent[] {
401  const running = list.filter(a => a.isRunning)
402  const finished = list.filter(a => !a.isRunning).sort((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0))
403
404  return [...running, ...finished.slice(0, AGENTS_KEPT)]
405}
406
407const finish = ({ doing: _doing, ...agent }: Agent, at: number): Agent => ({ ...agent, isRunning: false, finishedAt: at })
408
409function agentCounts(list: readonly Agent[]): string {
410  const running = list.filter(a => a.isRunning).length
411  const finished = list.length - running
412
413  return [running > 0 ? `${running} running` : '', finished > 0 ? `${finished} done` : ''].filter(Boolean).join(', ')
414}
415
416async function noteFill($: EngineInterface): Promise<void> {
417  const { context } = await $.session.usage({ breakdown: 'summary' })
418  const parts = (context.breakdown?.categories ?? [])
419    .filter(c => c.kind === 'used')
420    .sort((a, b) => b.tokens - a.tokens)
421    .slice(0, FILL_PARTS)
422    .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens }))
423
424  await update($, fill, last => ({ ...last, parts }))
425}
426
427/**
428 * @return [String] a tool result's name in the pane: the tool without its MCP prefix, and the
429 *   file it read or wrote
430 */
431function resultName(tool: string, input: Record<string, unknown>): string {
432  const short = tool.split('__').pop() ?? tool
433  const file = typeof input.file_path === 'string' ? input.file_path.split('/').pop() : undefined
434
435  return file === undefined ? short : `${short} ${file}`
436}
437
438const listed = (parts: readonly Part[]) => parts.map(p => `${p.name} ${tokens(p.tokens)}`).join(', ')
439
440/**
441 * @return [Repo, undefined] the branch, changed files and distance from upstream in
442 *   `git status --porcelain --branch` output, or undefined when it has no branch line
443 */
444function repoFrom(status: string): Omit<Repo, 'pr' | 'prCheckedAt'> | undefined {
445  const [head, ...files] = status.split('\n')
446  const match = /^## (?:No commits yet on )?(.+?)(?:\.\.\.\S+)?(?: \[(.*)\])?$/.exec(head ?? '')
447  const branch = match?.[1]
448  if (branch === undefined) return undefined
449
450  const ahead = Number(/ahead (\d+)/.exec(match?.[2] ?? '')?.[1] ?? 0)
451  const behind = Number(/behind (\d+)/.exec(match?.[2] ?? '')?.[1] ?? 0)
452
453  return { branch, changed: files.filter(f => f.trim() !== '').length, ahead, behind }
454}
455
456/**
457 * @return [String] how a PR's checks stand: any failed is failing, any unfinished is running
458 */
459function checksOf(rollup: unknown): Checks {
460  const runs = Array.isArray(rollup) ? (rollup as { status?: string; conclusion?: string | null; state?: string }[]) : []
461  if (runs.length === 0) return 'none'
462
463  const outcomes = runs.map(run => (run.conclusion ?? run.state ?? '').toUpperCase())
464  if (outcomes.some(o => FAILED_CHECKS.includes(o))) return 'failing'
465  if (runs.some(run => run.status !== undefined && run.status !== 'COMPLETED') || outcomes.some(o => o === '' || o === 'PENDING')) {
466    return 'running'
467  }
468
469  return 'passing'
470}
471
472function prFrom(json: string): PullRequest | undefined {
473  try {
474    const view = JSON.parse(json) as { number?: unknown; state?: unknown; statusCheckRollup?: unknown }
475    if (typeof view.number !== 'number') return undefined
476
477    return { number: view.number, state: String(view.state ?? '').toLowerCase(), checks: checksOf(view.statusCheckRollup) }
478  } catch {
479    return undefined
480  }
481}
482
483async function refreshRepo($: EngineInterface): Promise<void> {
484  const status = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v1', '--branch'], { timeoutMs: 5_000 })
485  const found = status.exitCode === 0 ? repoFrom(status.stdout) : undefined
486  if (found === undefined) {
487    await update($, repo, () => null)
488    return
489  }
490
491  const at = await $.clock.now()
492  const last = await read($, repo)
493  if (last !== null && last.branch === found.branch && at - (last.prCheckedAt ?? -Infinity) < PR_REFRESH_MS) {
494    await update($, repo, () => ({ ...last, ...found }))
495    return
496  }
497
498  const view = await $.process
499    .run(['gh', 'pr', 'view', '--json', 'number,state,statusCheckRollup'], { timeoutMs: 15_000 })
500    .catch(() => undefined)
501  const pr = view?.exitCode === 0 ? prFrom(view.stdout) : undefined
502
503  await update($, repo, () => ({ ...found, prCheckedAt: at, ...(pr === undefined ? {} : { pr }) }))
504}
505
506const laterRefreshRepo = ($: EngineInterface) => $.clock.after(0, () => void refreshRepo($).catch(() => undefined))
507
508async function noteStep($: EngineInterface, e: TurnStepInput): Promise<void> {
509  const ran = e.effort === undefined ? { model: e.model } : { model: e.model, effort: e.effort }
510  const id = e.agentId
511
512  if (id === undefined) {
513    const last = await read($, model)
514    if (last?.model !== ran.model || last.effort !== e.effort) await update($, model, () => ran)
515    return
516  }
517
518  await update($, agents, list => list.map(a => (a.id === id ? { ...a, ...ran } : a)))
519}
520
521const fit = (text: string, width: number) =>
522  text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
523
524type Run = { glyph: string; color: string }
525
526/**
527 * @return [Array<Run>] a gauge `width` cells long, `percent` of it filled in `color` on a grey
528 *   track, with a warning tick at `tick` percent and, with `trail`, red dashes past the fill
529 */
530function gauge(width: number, percent: number, color: string, { tick, trail }: { tick?: number; trail?: boolean } = {}): Run[] {
531  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
532  const cells = Array.from({ length: width }, (_, i): Run =>
533    i < filled ? { glyph: '━', color } : trail ? { glyph: '┅', color: 'error' } : { glyph: '━', color: TRACK },
534  )
535  if (tick !== undefined) cells[Math.min(width - 1, Math.round((tick / 100) * width))] = { glyph: '┃', color: 'warning' }
536
537  return cells.reduce<Run[]>((runs, cell) => {
538    const last = runs.at(-1)
539    if (last?.color === cell.color && last.glyph.at(-1) === cell.glyph) last.glyph += cell.glyph
540    else runs.push({ ...cell })
541    return runs
542  }, [])
543}
544
545function tokens(n: number): string {
546  if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`
547
548  return n >= 1_000 ? `${Math.round(n / 1_000)}k` : String(n)
549}
550
551const tone = (percent: number, warnAt: number, alarmAt: number) =>
552  percent >= alarmAt ? 'error' : percent >= warnAt ? 'warning' : 'success'
553
554type RawRoutine = {
555  id?: string
556  name?: string
557  enabled?: boolean
558  next_run_at?: string
559  last_run?: { status?: string; fired_at?: string } | null
560}
561
562/**
563 * @return [Array<Routine>, undefined] the routines in a RemoteTrigger list answer, enabled ones
564 *   by next run first, or undefined when the answer holds no listing
565 */
566function parseRoutines(text: string): Routine[] | undefined {
567  const start = text.indexOf('{')
568  const end = text.lastIndexOf('}')
569  if (start < 0 || end < start) return undefined
570
571  try {
572    const body = JSON.parse(text.slice(start, end + 1)) as { data?: RawRoutine[] }
573    if (!Array.isArray(body.data)) return undefined
574
575    const items = body.data.map(raw => ({
576      id: raw.id ?? '',
577      name: raw.name ?? raw.id ?? 'routine',
578      isEnabled: raw.enabled === true,
579      nextRunAt: raw.next_run_at,
580      lastFiredAt: raw.last_run?.fired_at,
581      lastStatus: raw.last_run?.status?.replace('ROUTINE_RUN_STATUS_', '').toLowerCase(),
582    }))
583
584    return items.sort(
585      (a, b) =>
586        Number(b.isEnabled) - Number(a.isEnabled) ||
587        (a.nextRunAt ?? '').localeCompare(b.nextRunAt ?? ''),
588    )
589  } catch {
590    return undefined
591  }
592}
593
594async function refreshRoutines($: EngineInterface): Promise<void> {
595  const ran = await $.tool.call({ tool: 'RemoteTrigger', action: 'list' })
596  const fetchedAt = await $.clock.now()
597  const items = ran.deny === undefined && ran.isError !== true ? parseRoutines(ran.text ?? '') : undefined
598  const error = ran.deny ?? fit(ran.text ?? 'no answer', 120)
599
600  await update($, routines, last =>
601    items ? { fetchedAt, items } : { fetchedAt, items: last?.items ?? [], error },
602  )
603}
604
605function parsePlan(path: string, text: string): Plan {
606  const heading = /^#[ \t]+(.+)$/m.exec(text)?.[1] ?? path.split('/').pop() ?? path
607  const title = heading.replace(/`/g, '').replace(/\s+implementation plan$/i, '').trim()
608  const project = /([^/]+)\/docs\/superpowers\/plans\//.exec(path)?.[1]
609  const tasks = [...text.matchAll(TASK_HEADING)].map(match => ({
610    n: Number(match[1] ?? 0),
611    title: (match[2] ?? '').replace(/`/g, '').trim(),
612    status: 'todo' as TaskStatus,
613  }))
614
615  return { path, title: project ? `${project} · ${title}` : title, tasks }
616}
617
618async function loadPlan($: EngineInterface, path: string): Promise<void> {
619  const fresh = parsePlan(path, await $.fs.read(path))
620  if (fresh.tasks.length === 0) return
621
622  const saved = ((await $.store.get(storeKey(path))) ?? {}) as Record<string, unknown>
623
624  await update($, plan, last => ({
625    ...fresh,
626    tasks: fresh.tasks.map(task => {
627      const kept = last?.path === path ? last.tasks.find(t => t.n === task.n)?.status : undefined
628      const stored = saved[String(task.n)]
629
630      return { ...task, status: kept ?? (isStatus(stored) ? stored : 'todo') }
631    }),
632  }))
633}
634
635async function tick($: EngineInterface): Promise<void> {
636  const at = await $.clock.now()
637  await update($, clock, () => at)
638}
639
640async function seed($: EngineInterface, settings: Settings): Promise<void> {
641  const { context, rateLimits } = await $.session.usage()
642  const seeded = toUsage(context, rateLimits)
643  await update($, usage, () => seeded)
644  $.ui.status(statusText(seeded, settings.statusLine))
645  await tick($)
646  await refreshFollowUps($, settings.followUps).catch(() => undefined)
647}
648
649export const register: Register = (on, options) => {
650  const settings = settingsFrom(options)
651
652  on('session.start', async ($, e, next) => {
653    await $.command.register({
654      name: 'deck',
655      description: 'Open the session deck: context, limits, plan progress and scheduled tasks',
656    })
657    await $.tool.register({
658      name: 'plan_progress',
659      description: PLAN_TOOL_DESCRIPTION,
660      inputSchema: {
661        type: 'object',
662        properties: {
663          task: { type: 'integer', description: "N from the plan's '### Task N:' heading" },
664          status: { type: 'string', enum: ['todo', 'doing', 'done'] },
665        },
666        required: ['task', 'status'],
667      },
668      isDeferred: false,
669    })
670
671    await seed($, settings)
672
673    if (e.isInteractive) {
674      $.clock.every(60_000, () => void tick($))
675      $.clock.every(ROUTINE_REFRESH_MS, () => void refreshRoutines($))
676      $.clock.after(0, () => void refreshRoutines($))
677      laterRefreshRepo($)
678      void $.ui.open({ id: PANE, title: TITLE })
679    }
680
681    return next(e)
682  })
683
684  on('classic.SessionStart', async ($, e, next) => {
685    if (e.source === 'clear') {
686      await seed($, settings)
687      $.clock.after(0, () => void refreshRoutines($))
688      laterRefreshRepo($)
689    }
690
691    return next(e)
692  }).catch(($, e, next) => next(e))
693
694  on('command.run', { command: 'deck' }, async $ => {
695    const opened = await $.ui.open({ id: PANE, title: TITLE })
696    $.clock.after(0, () => void refreshRoutines($))
697
698    return { text: opened.isPlaced ? 'Session deck opened.' : `Session deck waits: ${opened.reason}` }
699  })
700
701  on('session.measure', async ($, e, next) => {
702    const before = await read($, usage)
703    const after = toUsage(e.context, e.rateLimits)
704    const at = await $.clock.now()
705
706    await update($, usage, () => after)
707    $.ui.status(statusText(after, settings.statusLine))
708    if (reachedHandoff(before, after, settings)) await update($, nudge, () => 'due')
709
710    const paced = await paceAlerts($, after.limits, at).catch(() => [])
711    const lines = [...alerts(before, after, at, settings), ...paced]
712    for (const line of lines) $.ui.toast(line, { timeoutMs: 8000 })
713    if (e.changed.includes('context')) await noteFill($).catch(() => undefined)
714
715    return next(e)
716  })
717
718  on('classic.PostCompact', async ($, e, next) => {
719    await update($, fill, last => ({ ...last, results: [] }))
720
721    return next(e)
722  }).catch(($, e, next) => next(e))
723
724  on('prompt.submit', async ($, e, next) => {
725    if (e.command !== undefined || e.isMeta) return next(e)
726
727    const notes = [...(await briefing($, settings.checks)), ...(await handoffNotes($, settings.handoffAt))]
728
729    return notes.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...notes] })
730  }).catch(($, e, next) => next(e))
731
732  on('turn.step', async function* ($, e, next) {
733    await noteStep($, e).catch(() => undefined)
734
735    return yield* next(e)
736  })
737
738  on('agent.spawn', async ($, e, next) => {
739    const spawned = await next(e)
740
741    if (spawned.deny === undefined && spawned.agentId !== undefined) {
742      const row: Agent = {
743        id: spawned.agentId,
744        type: e.subagentType,
745        description: e.description,
746        model: spawned.model,
747        isRunning: true,
748      }
749      await update($, agents, list => kept([row, ...list.filter(a => a.id !== row.id)]))
750    }
751
752    return spawned
753  }).catch(($, e, next) => next(e))
754
755  on('classic.SubagentStop', async ($, e, next) => {
756    const at = await $.clock.now()
757    await update($, agents, list => kept(list.map(a => (a.id === e.agent_id ? finish(a, at) : a))))
758
759    return next(e)
760  }).catch(($, e, next) => next(e))
761
762  on('tool.call', async ($, e, next) => {
763    const id = e.agentId
764
765    if (id !== undefined) {
766      const now = doing(e.tool, e as Record<string, unknown>)
767      await update($, agents, list => list.map(a => (a.id === id && a.isRunning ? { ...a, doing: now } : a))).catch(
768        () => undefined,
769      )
770    }
771
772    const ran = await next(e)
773    const size = Math.round((ran.text?.length ?? 0) / 4)
774
775    if (id === undefined && size >= RESULT_MIN_TOKENS) {
776      const result = { name: resultName(e.tool, e as Record<string, unknown>), tokens: size }
777      await update($, fill, last => ({
778        ...last,
779        results: [...last.results, result].sort((x, y) => y.tokens - x.tokens).slice(0, FILL_PARTS),
780      })).catch(() => undefined)
781    }
782
783    const met = settings.checks.filter(c => meets(c, e.tool, e as Record<string, unknown>)).map(c => c.label)
784
785    if (met.length > 0 && ran.deny === undefined && ran.isError !== true) {
786      await update($, checklist, last => ({ ...last, ticked: [...new Set([...last.ticked, ...met])] })).catch(
787        () => undefined,
788      )
789    }
790
791    return ran
792  }).catch(($, e, next) => next(e))
793
794  on('classic.Stop', async ($, e, next) => {
795    await refreshFollowUps($, settings.followUps).catch(() => undefined)
796    laterRefreshRepo($)
797    await update($, wakeups, () => ({
798      crons: (e.session_crons ?? []).map(c => ({
799        id: c.id,
800        schedule: c.schedule,
801        isRecurring: c.recurring,
802        prompt: c.prompt,
803      })),
804      jobs: (e.background_tasks ?? []).map(t => ({
805        id: t.id,
806        type: t.type,
807        status: t.status,
808        description: t.description,
809      })),
810    }))
811
812    return next(e)
813  })
814
815  on('tool.call', { tool: ['Read', 'Write', 'Edit'] }, async ($, e, next) => {
816    const ran = await next(e)
817
818    if (ran.deny === undefined && ran.isError !== true && PLAN_FILE.test(e.file_path)) {
819      await loadPlan($, e.file_path).catch(() => undefined)
820    }
821
822    return ran
823  }).catch(($, e, next) => next(e))
824
825  on('tool.call', { tool: 'RemoteTrigger' }, async ($, e, next) => {
826    const ran = await next(e)
827
828    if (e.action === 'list' && e.cursor === undefined && ran.deny === undefined && ran.isError !== true) {
829      const items = parseRoutines(ran.text ?? '')
830      const fetchedAt = await $.clock.now()
831      if (items) await update($, routines, () => ({ fetchedAt, items }))
832    }
833
834    return ran
835  }).catch(($, e, next) => next(e))
836
837  on('tool.check', { tool: 'RemoteTrigger' }, ($, e, next) =>
838    next.origin.plugin === $.plugin.name && (e.input as { action?: unknown }).action === 'list'
839      ? { decision: 'allow', reason: 'session-deck reads the routine list' }
840      : next(e),
841  ).catch(($, e, next) => next(e))
842
843  on('tool.call', { tool: TOOL }, async ($, e) => {
844    const { task, status } = e as { task?: unknown; status?: unknown }
845    const current = await read($, plan)
846
847    if (current === null) {
848      return { deny: 'No plan is loaded. Read the docs/superpowers/plans/*.md file first.' }
849    }
850    if (!isStatus(status) || !current.tasks.some(t => t.n === task)) {
851      const numbers = current.tasks.map(t => t.n).join(', ')
852      return { deny: `Pass task as one of ${numbers} and status as todo, doing or done.` }
853    }
854
855    const marked = { ...current, tasks: current.tasks.map(t => (t.n === task ? { ...t, status } : t)) }
856    await update($, plan, () => marked)
857    await $.store.set(storeKey(marked.path), Object.fromEntries(marked.tasks.map(t => [t.n, t.status])))
858    const done = marked.tasks.filter(t => t.status === 'done').length
859
860    return { result: `${marked.title}: ${done} of ${marked.tasks.length} tasks done.` }
861  }).catch(() => ({ deny: 'session-deck could not record that; carry on without it.' }))
862
863  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
864    const { Box, Text } = $.ui.resolve(e)
865    const [u, r, w, p, m, a, held, f, fl, g, ticked] = await Promise.all([
866      read($, usage),
867      read($, routines),
868      read($, wakeups),
869      read($, plan),
870      read($, model),
871      read($, agents),
872      read($, checklist),
873      read($, followUps),
874      read($, fill),
875      read($, repo),
876      read($, clock),
877    ])
878    const at = ticked || (await $.clock.now())
879    const cols = e.props.bodyColumns
880
881    const section = (name: string, value: string, color?: string) => (
882      <Box marginTop={1} justifyContent="space-between">
883        <Text>
884          <Text weight="bold">{name}</Text> <Text color={TRACK}>{'─'.repeat(Math.max(1, cols - name.length - value.length - 2))}</Text>
885        </Text>
886        {color === undefined ? <Text dimColor>{value}</Text> : <Text color={color} weight="bold">{value}</Text>}
887      </Box>
888    )
889
890    const drawn = (runs: Run[]) => runs.map(run => <Text color={run.color}>{run.glyph}</Text>)
891
892    const contextTone = u?.percent === undefined ? undefined : tone(u.percent, settings.warnAt, settings.handoffAt)
893    const worst = [...(u?.limits ?? [])].sort((x, y) => y.percentUsed - x.percentUsed)[0]
894    const resets = (u?.limits ?? []).map(l => (l.resetsAt === undefined ? '' : `↻ ${relative(l.resetsAt, at).replace(/^in /, '')}`))
895    const resetWidth = Math.max(0, ...resets.map(reset => reset.length))
896    const checked = r === null ? 0 : Math.max(0, at - r.fetchedAt)
897    const scheduled = w.crons.length + w.jobs.length
898    const changes = g === null ? [] : [
899      g.changed > 0 ? `${g.changed} changed` : '',
900      g.ahead > 0 ? `${g.ahead} ahead` : '',
901      g.behind > 0 ? `${g.behind} behind` : '',
902    ].filter(Boolean)
903    const checksTone = { passing: 'success', failing: 'error', running: 'warning', none: undefined }
904    const checksDone = settings.checks.filter(c => held.ticked.includes(c.label)).length
905    const isChecked = checksDone === settings.checks.length
906    const owed = (f?.items ?? [])
907      .map((item, order) => ({ item, order, when: item.due === undefined ? undefined : dueIn(item.due, at) }))
908      .sort(
909        (x, y) =>
910          Number(y.when?.isDue ?? false) - Number(x.when?.isDue ?? false) ||
911          (x.item.due ?? '9999').localeCompare(y.item.due ?? '9999') ||
912          x.order - y.order,
913      )
914    const dueCount = owed.filter(o => o.when?.isDue).length
915    const followUpsValue =
916      f === null ? 'checking' : f.error !== undefined ? 'unreadable' : dueCount > 0 ? `${dueCount} due` : owed.length === 0 ? 'none' : `${owed.length} open`
917
918    const done = p?.tasks.filter(t => t.status === 'done').length ?? 0
919    const firstOpen = Math.max(0, p?.tasks.findIndex(t => t.status !== 'done') ?? 0)
920    const planStart = p && p.tasks.length > PLAN_ROWS ? Math.max(0, Math.min(firstOpen - 1, p.tasks.length - PLAN_ROWS)) : 0
921    const planRows = p?.tasks.slice(planStart, planStart + PLAN_ROWS) ?? []
922    const planRest = (p?.tasks.length ?? 0) - planStart - planRows.length
923
924    return (
925      <Box flexDirection="column">
926        {m === null ? (
927          <Text dimColor>Waiting for the first reply</Text>
928        ) : (
929          <Text>
930            <Text weight="bold">{modelName(m.model)}</Text>
931            {m.effort !== undefined && <Text dimColor>  {m.effort} effort</Text>}
932          </Text>
933        )}
934
935        {settings.checks.length > 0 &&
936          section('Checklist', isChecked ? 'all done' : `${checksDone} of ${settings.checks.length} done`, isChecked ? 'success' : 'warning')}
937        {!isChecked &&
938          settings.checks.map(c => {
939            const isTicked = held.ticked.includes(c.label)
940
941            return (
942              <Text dimColor={isTicked}>
943                <Text color={isTicked ? 'success' : undefined}>{isTicked ? '✓' : '·'}</Text> {c.label}
944              </Text>
945            )
946          })}
947
948        {section('Context', u?.percent === undefined ? 'no reading' : `${u.percent}%`, contextTone)}
949        {u?.percent !== undefined && contextTone !== undefined && (
950          <Box flexDirection="column">
951            <Text>{drawn(gauge(cols, u.percent, contextTone, { tick: settings.handoffAt }))}</Text>
952            <Box justifyContent="space-between">
953              <Text dimColor>{u.tokens === undefined ? '' : `${tokens(u.tokens)} of ${tokens(u.window)} tokens`}</Text>
954              <Text dimColor>handoff at {settings.handoffAt}%</Text>
955            </Box>
956          </Box>
957        )}
958        {fl.parts.length > 0 && <Text dimColor>{fit(listed(fl.parts), cols)}</Text>}
959        {fl.results.length > 0 && <Text dimColor>{fit(`largest results: ${listed(fl.results)}`, cols)}</Text>}
960
961        {section(
962          'Limits',
963          worst === undefined ? 'no reading' : `${label(worst.kind)} ${Math.round(worst.percentUsed)}%`,
964          worst === undefined ? undefined : tone(worst.percentUsed, LIMIT_WARN_AT, LIMIT_ALARM_AT),
965        )}
966        {(u?.limits ?? []).map((l, i) => {
967          const ahead = pace(l, at)
968          const reset = resets[i] ?? ''
969          const width = Math.max(6, cols - resetWidth - 12)
970
971          return (
972            <Box flexDirection="column">
973              <Box justifyContent="space-between">
974                <Text>
975                  {label(l.kind).padEnd(3)} {drawn(gauge(width, l.percentUsed, tone(l.percentUsed, LIMIT_WARN_AT, LIMIT_ALARM_AT), { trail: ahead !== undefined }))}{' '}
976                  {String(Math.round(l.percentUsed)).padStart(3)}%
977                </Text>
978                {reset !== '' && <Text dimColor>{reset.padStart(resetWidth)}</Text>}
979              </Box>
980              {ahead !== undefined && <Text color="warning">    {ahead}</Text>}
981            </Box>
982          )
983        })}
984
985        {g !== null && section('Repo', g.branch)}
986        {g !== null && <Text dimColor>{changes.length === 0 ? 'clean' : changes.join(', ')}</Text>}
987        {g?.pr !== undefined && (
988          <Text color={checksTone[g.pr.checks]} dimColor={g.pr.checks === 'none'}>
989            {fit(`PR #${g.pr.number} ${g.pr.state}, ${g.pr.checks === 'none' ? 'no checks' : `checks ${g.pr.checks}`}`, cols)}
990          </Text>
991        )}
992
993        {section('Plan', p === null ? 'none' : `${done} of ${p.tasks.length} done`)}
994        {p === null ? (
995          <Text dimColor>Reading a docs/superpowers/plans file starts it</Text>
996        ) : (
997          <Box flexDirection="column">
998            <Text>{fit(p.title, cols)}</Text>
999            {planStart > 0 && <Text dimColor>  … {planStart} done above</Text>}
1000            {planRows.map(t => (
1001              <Text dimColor={t.status !== 'doing'}>
1002                <Text color={t.status === 'done' ? 'success' : t.status === 'doing' ? ACCENT : undefined}>
1003                  {t.status === 'done' ? '✓' : t.status === 'doing' ? '▸' : '·'}
1004                </Text>{' '}
1005                {fit(`${t.n} ${t.title}`, cols - 2)}
1006              </Text>
1007            ))}
1008            {planRest > 0 && <Text dimColor>  … {planRest} more</Text>}
1009          </Box>
1010        )}
1011
1012        {settings.followUps !== undefined &&
1013          section('Follow-ups', followUpsValue, dueCount > 0 ? 'warning' : f?.error !== undefined ? 'error' : undefined)}
1014        {settings.followUps !== undefined && f?.error !== undefined && <Text color="warning">{fit(f.error, cols)}</Text>}
1015        {settings.followUps !== undefined &&
1016          owed.slice(0, FOLLOW_UP_ROWS).map(({ item, when }) => (
1017            <Box justifyContent="space-between">
1018              <Text dimColor={!when?.isDue}>
1019                <Text color={when?.isDue ? 'warning' : undefined}>{when?.isDue ? '●' : '·'}</Text>{' '}
1020                {fit(item.text, Math.max(8, cols - (when?.label.length ?? 0) - 4))}
1021              </Text>
1022              {when !== undefined && (
1023                <Text color={when.isDue ? 'warning' : undefined} dimColor={!when.isDue}>
1024                  {when.label}
1025                </Text>
1026              )}
1027            </Box>
1028          ))}
1029        {settings.followUps !== undefined && owed.length > FOLLOW_UP_ROWS && (
1030          <Text dimColor>  … {owed.length - FOLLOW_UP_ROWS} more</Text>
1031        )}
1032
1033        {section('Agents', a.length === 0 ? 'none' : agentCounts(a))}
1034        {a.map(agent => {
1035          const model = agent.model === undefined ? undefined : runsOn(agent.model, agent.effort)
1036          const when = agent.isRunning ? '' : `${span(Math.max(0, at - (agent.finishedAt ?? at)))} ago`
1037          const room = Math.max(8, cols - agent.type.length - (model?.length ?? 0) - when.length - 7)
1038          const isCostly = COSTLY_MODEL.test(agent.model ?? '')
1039
1040          return (
1041            <Box flexDirection="column">
1042              <Box justifyContent="space-between">
1043                <Text>
1044                  <Text color={agent.isRunning ? ACCENT : 'success'}>{agent.isRunning ? '▸' : '✓'}</Text>{' '}
1045                  <Text weight="bold">{agent.type}</Text>{'  '}
1046                  {model !== undefined && (
1047                    <Text color={isCostly ? 'warning' : undefined} dimColor={!isCostly}>
1048                      {model}
1049                    </Text>
1050                  )}
1051                  {'  '}
1052                  {fit(agent.description, room)}
1053                </Text>
1054                {when !== '' && <Text dimColor>{when}</Text>}
1055              </Box>
1056              {agent.isRunning && agent.doing !== undefined && <Text dimColor>  ↳ {fit(agent.doing, cols - 4)}</Text>}
1057            </Box>
1058          )
1059        })}
1060
1061        {section('Scheduled', scheduled === 0 ? 'none' : String(scheduled))}
1062        {w.crons.map(c => (
1063          <Text>
1064            {c.isRecurring ? '⟳' : '◷'} <Text dimColor>{c.schedule}</Text> {fit(c.prompt, Math.max(8, cols - c.schedule.length - 3))}
1065          </Text>
1066        ))}
1067        {w.jobs.map(j => (
1068          <Text>
1069            <Text dimColor>{j.type} {j.status}</Text> {fit(j.description, Math.max(8, cols - j.type.length - j.status.length - 3))}
1070          </Text>
1071        ))}
1072
1073        {section(
1074          'Routines',
1075          r === null ? 'checking' : r.items.length === 0 ? 'none' : checked < 60_000 ? 'checked just now' : `checked ${span(checked)} ago`,
1076        )}
1077        {r?.error !== undefined && <Text color="warning">{fit(`Not refreshed: ${r.error}`, cols)}</Text>}
1078        {r?.items.map(item => {
1079          const glyph = !item.isEnabled ? ' ' : item.lastStatus === 'succeeded' ? '✓' : item.lastStatus === 'failed' ? '✗' : item.lastStatus === undefined ? '·' : '◌'
1080          const when = item.isEnabled && item.nextRunAt ? relative(item.nextRunAt, at) : 'off'
1081
1082          return (
1083            <Box justifyContent="space-between">
1084              <Text dimColor={!item.isEnabled}>
1085                <Text color={glyph === '✗' ? 'error' : glyph === '✓' ? 'success' : undefined}>{glyph}</Text>{' '}
1086                {fit(item.name, Math.max(8, cols - when.length - 3))}
1087              </Text>
1088              <Text dimColor>{when}</Text>
1089            </Box>
1090          )
1091        })}
1092      </Box>
1093    )
1094  })
1095}
1096
types/index.d.ts 86 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = { percent?: number; tokens?: number; window: number; limits: Limit[] }
4
5export type Routine = {
6  id: string
7  name: string
8  isEnabled: boolean
9  nextRunAt?: string
10  lastFiredAt?: string
11  lastStatus?: string
12}
13
14export type Routines = { fetchedAt: number; items: Routine[]; error?: string }
15
16export type Cron = { id: string; schedule: string; isRecurring: boolean; prompt: string }
17
18export type Job = { id: string; type: string; status: string; description: string }
19
20export type Wakeups = { crons: Cron[]; jobs: Job[] }
21
22export type TaskStatus = 'todo' | 'doing' | 'done'
23
24export type PlanTask = { n: number; title: string; status: TaskStatus }
25
26export type Plan = { path: string; title: string; tasks: PlanTask[] }
27
28export type Effort = string | number
29
30export type ModelInUse = { model: string; effort?: Effort }
31
32export type Agent = {
33  id: string
34  type: string
35  description: string
36  model?: string
37  effort?: Effort
38  doing?: string
39  isRunning: boolean
40  finishedAt?: number
41}
42
43export type Nudge = 'due' | 'sent'
44
45export type Checklist = { ticked: string[]; isBriefed: boolean }
46
47export type FollowUp = { text: string; due?: string }
48
49export type FollowUps = { items: FollowUp[]; error?: string }
50
51export type Part = { name: string; tokens: number }
52
53export type Fill = { parts: Part[]; results: Part[] }
54
55export type Checks = 'passing' | 'failing' | 'running' | 'none'
56
57export type PullRequest = { number: number; state: string; checks: Checks }
58
59export type Repo = {
60  branch: string
61  changed: number
62  ahead: number
63  behind: number
64  pr?: PullRequest
65  prCheckedAt?: number
66}
67
68declare module 'claude-code' {
69  interface PluginState {
70    'session-deck': {
71      usage: Shaped<Usage | null>
72      routines: Shaped<Routines | null>
73      wakeups: Shaped<Wakeups>
74      plan: Shaped<Plan | null>
75      model: Shaped<ModelInUse | null>
76      agents: Shaped<Agent[]>
77      nudge: Shaped<Nudge | null>
78      checklist: Shaped<Checklist>
79      followUps: Shaped<FollowUps | null>
80      fill: Shaped<Fill>
81      repo: Shaped<Repo | null>
82      now: number
83    }
84  }
85}
86