SLOPSHOPPER

autonomy-status

Shows the autonomous-mode and ship-goal status in the status line, above the prompt, and via /autonomy-status, in Cursor Dark colors.

newbandspinnerguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autonomy-status
› fix the failing auth test and add an audit log call ⏺ 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 › /autonomy-status ⎿ autonomy-status: No autonomy state in this project. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

autonomy-status

Claude Code mod that shows the autonomous-mode and ship-goal status.

Data

Read relative to the session's project root ($.session.root()):

  • .claude/agent-harness.json or a .claude/state/ directory marks a harness-aware repo (shows ○ manual when autonomy is off).
  • .claude/state/autonomy.on present means autonomy is ON.
  • .claude/state/goal.json holds the ship goal (status is active, escalated or done).

Outside a harness-aware repo (none of the three exist) the mod shows nothing.

Surfaces

  • No status line: $.ui.status renders as plain text under the prompt and the host offers no footer component for styled output, so the capsule lives in the band.
  • Band above the prompt, shown in any harness-aware repo: the capsule ● AUTONOMOUS (accent background, bold dark text) while autonomy is on, ○ manual (muted background) otherwise; when a goal exists, a second row with the clipped goal and PR #n.
  • Below the prompt: a PromptHint hook shows fix i/max · stop blocks n just under the textarea, then the engine's own hint line (pills stay live), then the status (escalated: <reason> in the escalated color, or done; omitted while active). Nothing is added when there is no goal to show.
  • /autonomy-status: multi-line summary of every field.
  • Toasts on transition: escalated, done, autonomy toggled on or off.

State refreshes every 3 s, on prompt submit and after each tool call.

Colors

Palette from the community "Cursor Dark Theme" (publisher flawlessnitin), file themes/my-custom-theme-color-theme.json of https://github.com/flawlessnitin/cursor-theme, theme label "Cursor Dark Theme".

RoleHexTheme key
Accent: badge background (ON), active, PR#4c9df3button.background, textLink.foreground
Badge text#141414terminal.background
Done#a3be8cterminal.ansiGreen
Escalated#bf616aterminal.ansiRed, errorForeground
Muted text#828282sideBar.foreground (#cccccc99) flattened over #141414

The theme defines no descriptionForeground, so muted text uses the flattened sideBar.foreground, VS Code's own description-text fallback tier. focusBorder is transparent in this theme, so the accent comes from the button and link blue. The palette lives in one const, PALETTE, in hooks/model.ts.

Checks

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 124 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import {
6  AUTONOMY_FLAG,
7  GOAL_FILE,
8  HARNESS_MARKERS,
9  isBandVisible,
10  parseGoal,
11  shownGoal,
12  summary,
13  toastsFor,
14} from './model'
15import { countersView, goalHeaderView, modeView, statusView } from './views'
16
17const POLL_MS = 3000
18const COMMAND = 'autonomy-status'
19
20const snapshotAtom = atom({ plugin: 'autonomy-status', key: 'snapshot' } as const, null)
21const startedAtAtom = atom({ plugin: 'autonomy-status', key: 'startedAt' } as const, 0)
22
23const readSnapshot = async ($: EngineInterface, startedAt: number): Promise<Snapshot> => {
24  const root = await $.session.root()
25  const goalPath = `${root}/${GOAL_FILE}`
26  const [markers, isOn, hasGoal] = await Promise.all([
27    Promise.all(HARNESS_MARKERS.map(marker => $.fs.exists(`${root}/${marker}`))),
28    $.fs.exists(`${root}/${AUTONOMY_FLAG}`),
29    $.fs.exists(goalPath),
30  ])
31  const hasHarness = markers.some(Boolean)
32  if (!hasGoal) return { hasHarness, isOn, goal: null, isGoalFresh: false }
33  const [text, stat] = await Promise.all([$.fs.read(goalPath), $.fs.stat(goalPath)])
34  return { hasHarness, isOn, goal: parseGoal(text), isGoalFresh: stat.mtimeMs >= startedAt }
35}
36
37const refreshOnce = async ($: EngineInterface): Promise<Snapshot | null> => {
38  try {
39    const next = await readSnapshot($, await read($, startedAtAtom))
40    const prev = await read($, snapshotAtom)
41    await update($, snapshotAtom, () => next)
42    if (prev !== null) toastsFor(prev, next).forEach(text => $.ui.toast(text))
43    return next
44  } catch (error) {
45    $.ui.log(`autonomy-status: refresh failed: ${String(error)}`, { to: 'debug' })
46    return null
47  }
48}
49
50let running: Promise<Snapshot | null> | undefined
51
52const refresh = ($: EngineInterface): Promise<Snapshot | null> => {
53  running ??= refreshOnce($).finally(() => {
54    running = undefined
55  })
56  return running
57}
58
59export const register: Register = on => {
60  on('session.start', async ($, e, next) => {
61    const now = await $.clock.now()
62    await update($, startedAtAtom, started => started || now)
63    await $.command.register({
64      name: COMMAND,
65      description: 'Show the autonomous-mode and goal status',
66    })
67    await refresh($)
68    $.clock.every(POLL_MS, () => void refresh($))
69
70    return next(e)
71  })
72
73  on('prompt.submit', ($, e, next) => {
74    void refresh($)
75
76    return next(e)
77  })
78
79  on('tool.call', async ($, e, next) => {
80    const ran = await next(e)
81    void refresh($)
82
83    return ran
84  })
85
86  on('command.run', { command: COMMAND }, async $ => {
87    const snapshot = await refresh($)
88
89    return { text: snapshot === null ? 'Autonomy status unavailable.' : summary(snapshot) }
90  })
91
92  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93    const snapshot = await read($, snapshotAtom)
94    if (e.props.hasSurvey || snapshot === null || !isBandVisible(snapshot)) return next(e)
95
96    const table = $.ui.resolve(e)
97    const { Box } = table
98
99    return (
100      <Box flexDirection="column">
101        <Box>{modeView(table, snapshot)}</Box>
102        {goalHeaderView(table, snapshot)}
103      </Box>
104    )
105  })
106
107  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
108    const snapshot = await read($, snapshotAtom)
109    const engineHint = await next(e)
110    if (snapshot === null || shownGoal(snapshot) === null) return engineHint
111
112    const table = $.ui.resolve(e)
113    const { Box } = table
114
115    return (
116      <Box flexDirection="column">
117        {countersView(table, snapshot)}
118        {engineHint}
119        {statusView(table, snapshot)}
120      </Box>
121    )
122  })
123}
124
hooks/model.ts 92 lines
1import type { Goal, GoalStatus, Snapshot } from '../types'
2
3export const PALETTE = {
4  accent: '#4c9df3',
5  onAccent: '#141414',
6  done: '#a3be8c',
7  escalated: '#bf616a',
8  muted: '#828282',
9} as const
10
11export const HARNESS_MARKERS = ['.claude/agent-harness.json', '.claude/state'] as const
12export const AUTONOMY_FLAG = '.claude/state/autonomy.on'
13export const GOAL_FILE = '.claude/state/goal.json'
14
15const GOAL_CLIP = 60
16const STATUSES: readonly GoalStatus[] = ['active', 'escalated', 'done']
17
18const asCount = (value: unknown): number =>
19  typeof value === 'number' && Number.isFinite(value) ? value : 0
20
21const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
22
23export const clip = (text: string, max: number = GOAL_CLIP): string =>
24  text.length <= max ? text : `${text.slice(0, max - 1)}…`
25
26export const parseGoal = (text: string): Goal | null => {
27  let raw: unknown
28  try {
29    raw = JSON.parse(text)
30  } catch {
31    return null
32  }
33  if (typeof raw !== 'object' || raw === null) return null
34  const fields = raw as Record<string, unknown>
35  const status = STATUSES.find(one => one === fields.status)
36  if (status === undefined) return null
37  const pr = fields.pr
38  return {
39    goal: asText(fields.goal),
40    criteria: Array.isArray(fields.criteria) ? fields.criteria.map(asText) : [],
41    branch: asText(fields.branch),
42    pr: typeof pr === 'number' || (typeof pr === 'string' && pr !== '') ? String(pr) : null,
43    headSha: asText(fields.head_sha),
44    fixIterations: asCount(fields.fix_iterations),
45    maxFixIterations: asCount(fields.max_fix_iterations),
46    stopBlocks: asCount(fields.stop_blocks),
47    totalBlocks: asCount(fields.total_blocks),
48    status,
49    reason: asText(fields.reason),
50  }
51}
52
53export const shownGoal = (s: Snapshot): Goal | null =>
54  s.goal !== null && (s.goal.status !== 'done' || s.isGoalFresh) ? s.goal : null
55
56export const isBandVisible = (s: Snapshot): boolean => s.hasHarness || s.isOn || shownGoal(s) !== null
57
58const prLabel = (goal: Goal): string => (goal.pr === null ? '' : ` #${goal.pr}`)
59
60export const summary = (s: Snapshot): string => {
61  if (!s.hasHarness && !s.isOn && s.goal === null) {
62    return 'No autonomy state in this project.'
63  }
64  const lines = [`Autonomy: ${s.isOn ? 'ON' : 'off'}`]
65  const goal = s.goal
66  if (goal === null) return [...lines, 'Goal: none'].join('\n')
67  lines.push(
68    `Goal: ${goal.goal}`,
69    `Status: ${goal.status}${goal.reason === '' ? '' : ` (${goal.reason})`}`,
70    `Branch: ${goal.branch === '' ? '-' : goal.branch}`,
71    `PR: ${goal.pr === null ? '-' : `#${goal.pr}`}`,
72    `Head: ${goal.headSha === '' ? '-' : goal.headSha}`,
73    `Fix iterations: ${goal.fixIterations}/${goal.maxFixIterations}`,
74    `Stop blocks: ${goal.stopBlocks} (total ${goal.totalBlocks})`,
75  )
76  if (goal.criteria.length > 0) {
77    lines.push('Criteria:', ...goal.criteria.map(one => `  - ${one}`))
78  }
79  return lines.join('\n')
80}
81
82export const toastsFor = (prev: Snapshot, next: Snapshot): string[] => {
83  const toasts: string[] = []
84  if (prev.isOn !== next.isOn) toasts.push(`Autonomy ${next.isOn ? 'ON' : 'OFF'}`)
85  const before = prev.goal?.status
86  const goal = next.goal
87  if (goal === null || before === goal.status) return toasts
88  if (goal.status === 'escalated') toasts.push(`Ship escalated: ${goal.reason || 'no reason given'}`)
89  if (goal.status === 'done') toasts.push(`Goal done: PR${prLabel(goal)} merged`)
90  return toasts
91}
92
hooks/views.ts 52 lines
1import type { EngineInterface, RenderElement } from 'claude-code'
2
3import type { Goal, Snapshot } from '../types'
4import { PALETTE, clip, shownGoal } from './model'
5
6export type Constructors = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Box' | 'Text'>
7
8export const modeView = ({ Text }: Constructors, snapshot: Snapshot): RenderElement =>
9  Text({
10    backgroundColor: snapshot.isOn ? PALETTE.accent : PALETTE.muted,
11    color: PALETTE.onAccent,
12    bold: true,
13    children: snapshot.isOn ? ' ● AUTONOMOUS ' : ' ○ manual ',
14  })
15
16const statusColor = (goal: Goal): string =>
17  goal.status === 'done'
18    ? PALETTE.done
19    : goal.status === 'escalated'
20      ? PALETTE.escalated
21      : PALETTE.accent
22
23export const goalHeaderView = ({ Box, Text }: Constructors, snapshot: Snapshot): RenderElement | null => {
24  const goal = shownGoal(snapshot)
25  if (goal === null) return null
26  const head = [
27    goal.goal !== '' ? Text({ children: clip(goal.goal) }) : null,
28    goal.pr !== null ? Text({ color: PALETTE.accent, children: ` PR #${goal.pr}` }) : null,
29  ]
30  return Box({ children: Text({ children: head }) })
31}
32
33export const countersView = ({ Box, Text }: Constructors, snapshot: Snapshot): RenderElement | null => {
34  const goal = shownGoal(snapshot)
35  if (goal === null) return null
36  return Box({
37    children: Text({
38      color: PALETTE.muted,
39      children: `fix ${goal.fixIterations}/${goal.maxFixIterations} · stop blocks ${goal.stopBlocks}`,
40    }),
41  })
42}
43
44export const statusView = ({ Box, Text }: Constructors, snapshot: Snapshot): RenderElement | null => {
45  const goal = shownGoal(snapshot)
46  if (goal === null || goal.status === 'active') return null
47  const reason = goal.status === 'escalated' && goal.reason !== '' ? `: ${goal.reason}` : ''
48  return Box({
49    children: Text({ color: statusColor(goal), bold: true, wrap: 'wrap', children: `${goal.status}${reason}` }),
50  })
51}
52
types/index.d.ts 29 lines
1export type GoalStatus = 'active' | 'escalated' | 'done'
2
3export type Goal = {
4  goal: string
5  criteria: readonly string[]
6  branch: string
7  pr: string | null
8  headSha: string
9  fixIterations: number
10  maxFixIterations: number
11  stopBlocks: number
12  totalBlocks: number
13  status: GoalStatus
14  reason: string
15}
16
17export type Snapshot = {
18  hasHarness: boolean
19  isOn: boolean
20  goal: Goal | null
21  isGoalFresh: boolean
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'autonomy-status': { snapshot: Snapshot | null; startedAt: number }
27  }
28}
29