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

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.
/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.gh at most every two minutes. Git runs without taking the index lock, so it never collides with your own git commands.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.26 Oct, Oct 26, 2026-10-26) shows when it is due, and the due and late ones come first in the warning colour./loop wakeups and background jobs, as of the end of the last turn.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%.
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.
The pane opens by itself on a terminal 144 columns wide or more. /deck opens it at any width and refreshes the routine list.
Each shows up as a row in /config, and a change there applies at once.
| Setting | Default | What it does |
|---|---|---|
Handoff mark (handoffAt) | 50 | The context percent the deck marks for a handoff. A toast fires when context reaches it, and a warning 10 points earlier. |
Status line (statusLine) | full | What 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) | empty | A markdown file and heading as path#Heading, such as ~/notes/status.md#Promised follow-ups. |
Session checklist (checklist) | empty | Items 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.
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.gh) signed in; without it the Repo section shows the branch alone.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.
MIT
hooks/register.tsx 1096 lines1import { 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}
1096types/index.d.ts 86 lines1export 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