AgentKit cockpit above the prompt: context fill with a handoff nudge, and the active plan's phase and checklist.

An AgentKit cockpit above the Claude Code prompt.
handoffPercent option) the reading turns red, a toast fires once per crossing, and a Handoff button runs /ak:handoff. Nothing is ever blocked.plans/ whose plan.md changed last, read with ak plan parse --json. The band shows the current phase and how many of its tasks are checked. Press it to open a pane listing the phases and the open checklist items of the current phase.Requires the ak CLI on PATH and the ak:handoff skill.
Nothing leaves your machine. The mod makes no network calls; what it shows stays in your terminal or desktop app.
ak plan parse <plans/that-plan> --json, a read-only parse of the newest plan, run only when that plan's plan.md changed since the last read.plans/, each plans/*/plan.md's modification time, and the current phase's file, for its checklist. It writes nothing.tokens, percent) after each main-thread turn. It does not read messages./ak:handoff, and only when you press the Handoff button.hooks/register.tsx 148 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PlanView } from '../types'
5import { forecast, shortTokens } from './lib/context'
6import { parseChecklist, planFromParse, planLine } from './lib/plan'
7
8const PANE = 'ak-cockpit-plan'
9const HANDOFF = 'ak:handoff'
10/** Rows the pane's body spends besides the phase list and the open items: title, totals, the "còn" line, the overflow line. */
11const PANE_FIXED_ROWS = 4
12
13const context = atom({ plugin: 'ak-cockpit', key: 'context' } as const, null)
14const isWarned = atom({ plugin: 'ak-cockpit', key: 'isWarned' } as const, false)
15const plan = atom({ plugin: 'ak-cockpit', key: 'plan' } as const, null)
16
17/** The plan directory under `plans/` whose plan.md was written last, with that mtime. */
18async function newestPlan($: EngineInterface): Promise<{ dir: string; mtimeMs: number } | null> {
19 const base = `${(await $.session.cwd()).replace(/\/$/, '')}/plans`
20 const entries = await $.fs.list(base).catch(() => [])
21 let best: { dir: string; mtimeMs: number } | null = null
22 for (const entry of entries) {
23 if (entry.kind !== 'dir') continue
24 const dir = `${base}/${entry.name}`
25 const stat = await $.fs.stat(`${dir}/plan.md`).catch(() => null)
26 if (stat && (!best || stat.mtimeMs > best.mtimeMs)) best = { dir, mtimeMs: stat.mtimeMs }
27 }
28 return best
29}
30
31/** Rereads the plan when plan.md moved; always rereads the current phase's checklist, which changes on its own. */
32async function refreshPlan($: EngineInterface): Promise<void> {
33 const found = await newestPlan($)
34 if (!found) {
35 await update($, plan, () => null)
36 return
37 }
38 const last = await read($, plan)
39 let base: Omit<PlanView, 'checklist' | 'dir' | 'mtimeMs'> | null = last && last.dir === found.dir && last.mtimeMs === found.mtimeMs ? last : null
40 if (!base) {
41 const ran = await $.process.run(['ak', 'plan', 'parse', found.dir, '--json'], { timeoutMs: 10000 }).catch(() => null)
42 base = ran && ran.exitCode === 0 ? planFromParse(ran.stdout) : null
43 }
44 if (!base) {
45 await update($, plan, () => null)
46 return
47 }
48 const phase = base.phases[base.current]
49 const text = phase ? await $.fs.read(phase.file).catch(() => '') : ''
50 const checklist = parseChecklist(typeof text === 'string' ? text : '')
51 const view: PlanView = { ...base, checklist, dir: found.dir, mtimeMs: found.mtimeMs }
52 await update($, plan, () => view)
53}
54
55async function refreshContext($: EngineInterface, threshold: number): Promise<void> {
56 const usage = (await $.session.usage()).context
57 if (usage.percent === undefined || usage.tokens === undefined) {
58 // Right after a compact or /clear there is no reading yet: drop the old one rather than show it.
59 await update($, context, () => null)
60 await update($, isWarned, () => false)
61 return
62 }
63 const percent = usage.percent
64 const tokens = usage.tokens
65 const last = await read($, context)
66 await update($, context, () => ({ percent, tokens, delta: last ? tokens - last.tokens : 0 }))
67 if (percent >= threshold && !(await read($, isWarned))) {
68 await update($, isWarned, () => true)
69 $.ui.toast(`Context ${percent}% — nên chạy /${HANDOFF} trước khi bị compact`, { timeoutMs: 8000 })
70 } else if (percent < threshold) {
71 // A compact brings it back under; the next crossing warns again.
72 await update($, isWarned, () => false)
73 }
74}
75
76export const register: Register = (on, options) => {
77 const asked = Number(options.handoffPercent)
78 const threshold = Number.isFinite(asked) && asked >= 1 && asked <= 100 ? Math.round(asked) : 75
79
80 on('session.start', async ($, e, next) => {
81 const started = await next(e)
82 await refreshPlan($).catch(() => undefined)
83 return started
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 const done = await next(e)
88 // A subagent's turn says nothing about the main window, and would refresh the plan once per agent.
89 if (e.agentId !== undefined) return done
90 await refreshContext($, threshold).catch(() => undefined)
91 await refreshPlan($).catch(() => undefined)
92 return done
93 })
94
95 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
96 const below = await next(e)
97 if (e.props.hasSurvey) return below
98 const reading = await read($, context)
99 const view = await read($, plan)
100 if (!reading && !view) return below
101 const { Box, Text, Button } = $.ui.resolve(e)
102 const weather = reading ? forecast(reading.percent, threshold) : null
103 const isHot = reading !== null && reading.percent >= threshold
104 const handoff = () =>
105 $.command.run({ command: HANDOFF }).catch(() => $.ui.toast(`Không chạy được /${HANDOFF}: skill chưa được cài?`))
106 return (
107 <Box flexDirection="column">
108 <Box key="cockpit" gap={2} width={e.props.bodyColumns}>
109 {reading && weather && (
110 <Box key="context" gap={1} flexShrink={0}>
111 <Text key="reading" color={weather.color} bold={isHot}>
112 {weather.glyph} Context {reading.percent}% ({shortTokens(reading.delta)}) · handoff ở {threshold}%
113 </Text>
114 {isHot && <Button key="handoff" label="Handoff" variant="primary" onPress={handoff} />}
115 </Box>
116 )}
117 {view && <Button key="plan" plain label={planLine(view)} onPress={() => $.ui.open({ id: PANE, title: view.title })} />}
118 </Box>
119 {below}
120 </Box>
121 )
122 })
123
124 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
125 const { Box, Text } = $.ui.resolve(e)
126 const view = await read($, plan)
127 if (!view) return <Text dimColor>Không tìm thấy plan nào trong plans/.</Text>
128 if (!view.phases.length) return <Text dimColor>{view.title}: plan chưa có phase nào.</Text>
129 const open = view.checklist.filter(item => !item.isDone)
130 const room = Math.max(3, e.props.scroll.bodyRows - view.phases.length - PANE_FIXED_ROWS)
131 const shown = open.length > room ? room - 1 : open.length
132 return (
133 <Box flexDirection="column" width={e.props.bodyColumns}>
134 <Text key="title" bold wrap="truncate-end">{view.title}</Text>
135 <Text key="totals" dimColor>{view.doneTasks}/{view.totalTasks} task xong</Text>
136 {view.phases.map((phase, i) => (
137 <Text key={`phase-${phase.number}`} color={i === view.current ? 'cyan' : undefined} dimColor={i !== view.current && phase.doneTasks === phase.totalTasks} wrap="truncate-end">
138 {i === view.current ? '▸' : i < view.current || view.current < 0 ? '✓' : ' '} {phase.number}. {phase.title} ({phase.doneTasks}/{phase.totalTasks})
139 </Text>
140 ))}
141 {view.current >= 0 && <Text key="open" bold>Phase {view.phases[view.current]?.number} — còn {open.length} mục:</Text>}
142 {open.slice(0, shown).map((item, i) => <Text key={`item-${i}`} wrap="truncate-end"> ☐ {item.text}</Text>)}
143 {open.length > shown && <Text key="overflow" dimColor> … và {open.length - shown} mục khác</Text>}
144 </Box>
145 )
146 })
147}
148hooks/lib/context.ts 19 lines1/**
2 * The weather glyph for a context fill, after Token Weather's scale, with the handoff threshold as the storm line.
3 * Calm levels keep the surface's own text color so they read on light and dark themes alike;
4 * only the two warning levels take a theme color.
5 */
6export function forecast(percent: number, threshold: number): { glyph: string; color?: 'warning' | 'error' } {
7 if (percent >= threshold) return { glyph: '↯', color: 'error' }
8 if (percent >= threshold - 15) return { glyph: '☂', color: 'warning' }
9 if (percent >= 25) return { glyph: '☁' }
10 return { glyph: '☀' }
11}
12
13/** `4.1k`, `820`, `-1.2k`: a signed token count short enough for one band. */
14export function shortTokens(n: number): string {
15 const sign = n > 0 ? '+' : n < 0 ? '-' : '±'
16 const abs = Math.abs(n)
17 return `${sign}${abs >= 1000 ? `${(abs / 1000).toFixed(1)}k` : abs}`
18}
19hooks/lib/plan.ts 55 lines1import type { ChecklistItem, PhaseView, PlanView } from '../../types'
2
3type ParsedPhase = { number: number; title: string; status?: string; total_tasks: number; done_tasks: number; file_path: string }
4type Parsed = { data: { name: string; title: string; total_tasks: number; done_tasks: number; phases: ParsedPhase[] | null } }
5
6const DONE = new Set(['done', 'completed', 'complete'])
7
8/** A phase is open while it has unchecked tasks, or, with none counted, until its status says done. */
9export const isOpen = (p: PhaseView) => (p.totalTasks > 0 ? p.doneTasks < p.totalTasks : !DONE.has(p.status))
10
11/** Turns `ak plan parse --json` output into the band's view, checklist and source left empty; null when it does not parse. */
12export function planFromParse(json: string): Omit<PlanView, 'checklist' | 'dir' | 'mtimeMs'> | null {
13 let parsed: Parsed
14 try {
15 parsed = JSON.parse(json) as Parsed
16 } catch {
17 return null
18 }
19 const data = parsed?.data
20 if (!data || typeof data.name !== 'string') return null
21 const phases: PhaseView[] = (data.phases ?? []).map(p => ({
22 number: p.number,
23 title: p.title,
24 status: p.status ?? '',
25 doneTasks: p.done_tasks,
26 totalTasks: p.total_tasks,
27 file: p.file_path,
28 }))
29 return {
30 name: data.name,
31 title: data.title,
32 doneTasks: data.done_tasks,
33 totalTasks: data.total_tasks,
34 phases,
35 current: phases.findIndex(isOpen),
36 }
37}
38
39/** Reads the `- [ ]` and `- [x]` lines of a markdown file. */
40export function parseChecklist(markdown: string): ChecklistItem[] {
41 const items: ChecklistItem[] = []
42 for (const match of markdown.matchAll(/^\s*[-*] \[([ xX])\] (.+)$/gm)) {
43 items.push({ isDone: match[1] !== ' ', text: (match[2] ?? '').trim() })
44 }
45 return items
46}
47
48/** One-line summary: `▸ name · Phase 3/5 · 7/12 ☑`. */
49export function planLine(plan: Pick<PlanView, 'name' | 'phases' | 'current'>): string {
50 if (!plan.phases.length) return `▸ ${plan.name} · chưa có phase`
51 const phase = plan.phases[plan.current]
52 if (!phase) return `▸ ${plan.name} · xong ${plan.phases.length}/${plan.phases.length} phase`
53 return `▸ ${plan.name} · Phase ${phase.number}/${plan.phases.length} · ${phase.doneTasks}/${phase.totalTasks} ☑`
54}
55types/index.d.ts 43 lines1/** The context window's fill after the last turn. */
2export type ContextReading = {
3 /** Whole percent of the window in use. */
4 percent: number
5 /** Input tokens the last response was answered over. */
6 tokens: number
7 /** Change in tokens since the turn before. */
8 delta: number
9}
10
11/** One checkbox line of a phase file. */
12export type ChecklistItem = { text: string; isDone: boolean }
13
14/** A plan phase as `ak plan parse` reports it. */
15export type PhaseView = { number: number; title: string; status: string; doneTasks: number; totalTasks: number; file: string }
16
17/** The plan the band follows: the one whose plan.md changed last. */
18export type PlanView = {
19 name: string
20 title: string
21 doneTasks: number
22 totalTasks: number
23 phases: PhaseView[]
24 /** Index into `phases` of the first phase still open; -1 when all are done or there are none. */
25 current: number
26 /** The checklist of the current phase, read with the plan so the two never disagree. */
27 checklist: ChecklistItem[]
28 /** plan.md's directory and mtime, so an unchanged plan is not parsed again. */
29 dir: string
30 mtimeMs: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'ak-cockpit': {
36 context: ContextReading | null
37 /** True once the threshold toast fired for the current crossing. */
38 isWarned: boolean
39 plan: PlanView | null
40 }
41 }
42}
43