SLOPSHOPPER

ak-cockpit

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

newpanebandtoastprocess
v0.1.0MITupdated 2026-10-02thieung/claude-mods/ak-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ak-cockpit
│ ┃ ak-cockpit-plan ✕ › fix the failing auth test and add an audit log call │ ┃ Không tìm thấy plan nào trong plans/. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ☁ Context 49% (±0) · handoff ở 75% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Context 49% (±0) · handoff ở 75% ⟨Claude Code's own drawing⟩
Pane · ak-cockpit-plan
Không tìm thấy plan nào trong plans/.
README

ak-cockpit

An AgentKit cockpit above the Claude Code prompt.

  • Context. Shows how full the context window is and how much the last turn added. At the handoff threshold (75% by default, set through the handoffPercent option) the reading turns red, a toast fires once per crossing, and a Handoff button runs /ak:handoff. Nothing is ever blocked.
  • Plan. Follows the plan in 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.

What it runs, reads and sends

Nothing leaves your machine. The mod makes no network calls; what it shows stays in your terminal or desktop app.

  • Programs it starts: 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.
  • Files it reads: the entries of plans/, each plans/*/plan.md's modification time, and the current phase's file, for its checklist. It writes nothing.
  • What it reads from the session: the context window's fill (tokens, percent) after each main-thread turn. It does not read messages.
  • Slash commands it runs: /ak:handoff, and only when you press the Handoff button.
Source 4 files
hooks/register.tsx 148 lines
1import { 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}
148
hooks/lib/context.ts 19 lines
1/**
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}
19
hooks/lib/plan.ts 55 lines
1import 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}
55
types/index.d.ts 43 lines
1/** 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