SLOPSHOPPER

abofles-guide

Abo Fles guide band: the 🧭 next line (what to do now, by priority) drawn above the prompt box, from the state the abofles hooks keep. Needs the abofles plugin.

newbandtoaststatusprocesstimer
★ 2v0.3.0MITupdated 2026-10-14Sajadh92/claude-plugins/plugins/abofles-guide
A shopper browsing a rack in a slop shop
README

abofles-guide

A band above the prompt box with the Abo Fles 🧭 next line: what to do now, by priority, computed by the abofles plugin's hooks after every spawn, receipt and prompt (open FAIL / ATTENTION · judged question unasked · owner decisions open · partial/low reply not escalated · background agents running · context or memo hygiene · agent budget nearly out · the memo's Next). The same line is in every receipt (- \next\ → 🧭 …) and resume block.

It only draws: a band with buttons. It never spawns, blocks or writes by itself; the only commands it runs are the ones the item carries, when you press Resolve or Undo.

  • Use puts the top item's fill text in the prompt box as the next prompt (appended after a draft you already typed) and takes the band down. If the prompt box is not open it says so and keeps the band: use Copy.
  • Re-check (a 🔴 top item that carries a re-check prompt; such an item has no Resolve) puts that prompt in the prompt box the same way.
  • Resolve (only when the top item carries a command) asks twice: the first press turns it into Confirm resolve for 5 s, the second runs the command in the project root and toasts the first line of its output. The arm belongs to that item (by its id): a different item with the same wording needs its own first press, and an arm older than 5 s (a reload, a late redraw) only re-arms. While the command runs (up to 30 s) Resolve and Undo are gone, so a second press cannot start a second run. After a success the band shrinks to ✓ resolved: … with Undo (runs the item's undo command, when it has one) and Hide, until the next turn completes; the row does not depend on the guide still being on disk. Each hand-resolve is written to the memo journal.
  • Exit code 3 from the command means nothing changed: Resolve toasts already resolved and offers no Undo; Undo toasts already open and clears the row. Any other failure toasts resolve failed / undo failed with the first line of the error; a failed Resolve keeps the band, a failed Undo clears the row.
  • Memo (when the context is within 50K of its next mark, or past the last one) puts /abofles:memo in the prompt box.
  • Copy puts the Use text on the clipboard.
  • Hide takes the band down. It is keyed on the ids of the items shown (plus the context mark when the memo is due), not on the emojis in the line: the band stays down while the same items are shown, even if their wording changes, and comes back when an item appears or goes, or when a newly crossed context mark makes the memo due again.

When nothing is open (every item ✅) the band is hidden, except when the memo is due: then it shows just the meter and Memo. A dim second line, the meter, shows ctx 412K / 500K · task 3 spawns · $0.41 · budget 2 left · suggest Opus 5.5 · Medium (parts without data omitted). A toast fires once for each 🔴 item that was not toasted before (by item id), not on every refresh while the top stays red. Fields of the state file with the wrong type are ignored (a bad item is dropped, a bad task or ctx left out) rather than taking the band away. The ✅ suggestion in the band follows the memo's Now, which the abofles hook rewrites after every receipt. It needs abofles installed; without it the band stays empty.

Install: /plugin install abofles-guide@sajadh92 (after /plugin marketplace add <this repo>), or claude --plugin-dir plugins/abofles-guide. Validate: claude plugin validate plugins/abofles-guide. Turn the line off everywhere with - Guide = off in the project's ## abofles overrides.

Source 2 files
hooks/register.tsx 309 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Guide, GuideItem } from '../types'
5
6// The abofles hooks (Python) compute the guide line after every spawn, receipt and prompt and keep it in the
7// session's state file as `guide.line`. This mod only draws it: a band above the prompt and a toast when a new
8// red item appears. It never spawns, blocks or writes.
9const guide = atom({ plugin: 'abofles-guide', key: 'guide' } as const, null)
10// What Hide compares: the ids of the shown items (+ the memo mark when the memo is due), see hideKeyOf.
11const hiddenKey = atom({ plugin: 'abofles-guide', key: 'hiddenKey' } as const, null)
12
13// Resolve asks twice: the first press arms the item (keyed by its id) for 5 s; the second runs the command.
14const armed = atom({ plugin: 'abofles-guide', key: 'armed' } as const, null)
15// The last hand-resolve, kept for Undo until the next turn completes.
16const lastResolved = atom({ plugin: 'abofles-guide', key: 'lastResolved' } as const, null)
17// Set while Resolve or Undo runs its command (up to 30 s): both buttons are gone, a second press is ignored.
18const running = atom({ plugin: 'abofles-guide', key: 'running' } as const, false)
19// The ids of the red items already toasted: a toast fires only for a red id not in it (kept across reloads).
20const toasted = atom({ plugin: 'abofles-guide', key: 'toasted' } as const, [] as string[])
21const ARM_MS = 5000
22
23const isStr = (v: unknown): v is string => typeof v === 'string'
24const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
25const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
26const argvOf = (v: unknown): string[] | null => (Array.isArray(v) && v.length > 0 && v.every(isStr) ? v : null)
27
28// The id of an item: stable per underlying thing; a state file from before ids has none, so its text stands in.
29const idOf = (it: GuideItem): string => it.id || it.text
30
31// One item from the state file, or null when it lacks a priority or text. Wrong-typed optional fields fall back.
32function cleanItem(v: unknown): GuideItem | null {
33  if (!isObj(v) || !isStr(v.p) || !isStr(v.text)) return null
34  const item: GuideItem = {
35    p: v.p,
36    text: v.text,
37    fill: isStr(v.fill) ? v.fill : '',
38    resolve: argvOf(v.resolve),
39    recheck: isStr(v.recheck) && v.recheck.trim() ? v.recheck : null,
40    unresolve: argvOf(v.unresolve),
41  }
42  if (isStr(v.id) && v.id) item.id = v.id
43  return item
44}
45
46// The guide from the state file with every field type-checked (older files lack the newer fields), or null.
47function cleanGuide(raw: unknown): Guide | null {
48  if (!isObj(raw) || !isStr(raw.line) || !raw.line) return null
49  let ctx: Guide['ctx'] = null
50  if (isObj(raw.ctx) && isNum(raw.ctx.tokens) && (raw.ctx.mark == null || isNum(raw.ctx.mark))) {
51    ctx = { tokens: raw.ctx.tokens, mark: raw.ctx.mark ?? null }
52  }
53  let task: Guide['task'] = null
54  if (isObj(raw.task) && isNum(raw.task.spawns) && isNum(raw.task.usd)) {
55    task = {
56      spawns: raw.task.spawns,
57      usd: raw.task.usd,
58      budget: isNum(raw.task.budget) ? raw.task.budget : null,
59      left: isNum(raw.task.left) ? raw.task.left : null,
60    }
61  }
62  const items = Array.isArray(raw.items) ? raw.items.map(cleanItem).filter((i): i is GuideItem => i !== null) : []
63  return { line: raw.line, ts: isNum(raw.ts) ? raw.ts : 0, root: isStr(raw.root) ? raw.root : '', items, ctx, task, main: isStr(raw.main) ? raw.main : null }
64}
65
66// Takes the "running" flag; false when a Resolve or Undo already holds it (a second press does nothing).
67async function claim($: EngineInterface): Promise<boolean> {
68  let wasRunning = false
69  await update($, running, cur => {
70    wasRunning = cur === true
71    return true
72  })
73  return !wasRunning
74}
75
76async function refresh($: EngineInterface) {
77  try {
78    const home = (await $.env.get('HOME')) ?? ''
79    const dir = (await $.env.get('ABOFLES_STATE_DIR')) ?? `${home}/.abofles/state`
80    const id = await $.session.id()
81    const text = await $.fs.read(`${dir}/${id}.json`)
82    const st = JSON.parse(text) as { guide?: unknown }
83    const next = cleanGuide(isObj(st) ? st.guide : null)
84    if (!next) {
85      await update($, guide, () => null)
86      return
87    }
88    await update($, guide, () => next)
89    // Toast when a red item appears that was not toasted before (an old state file without items: the line itself).
90    const red = next.items.filter(i => i.p === '🔴').map(idOf)
91    if (red.length === 0 && Array.from(keyOf(next.line))[0] === '🔴') red.push(`line:${next.line}`)
92    const seen = await read($, toasted)
93    if (red.some(r => !seen.includes(r))) {
94      // code points, not UTF-16 units: an emoji is two units
95      $.ui.toast(Array.from(next.line).slice(0, 160).join(''))
96    }
97    if (red.length !== seen.length || red.some((r, n) => r !== seen[n])) await update($, toasted, () => red)
98  } catch {
99    // no state yet (abofles not installed, or no hook ran): draw nothing
100    await update($, guide, () => null)
101  }
102}
103
104export const register: Register = on => {
105  on('session.start', async ($, e, next) => {
106    $.ui.status(undefined) // an entry an earlier build of this mod pinned: the band is enough
107    await refresh($)
108    return next(e)
109  })
110
111  on('turn.complete', async ($, e, next) => {
112    const result = await next(e)
113    await update($, lastResolved, () => null)
114    await update($, armed, () => null)
115    await refresh($)
116    return result
117  })
118
119  on('classic.Stop', async ($, e, next) => {
120    const result = await next(e)
121    await refresh($)
122    return result
123  }).catch(($, e, next) => next(e)) // a failed refresh must not change the Stop result
124
125  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
126    const g = await read($, guide)
127    const last = await read($, lastResolved)
128    const busy = (await read($, running)) === true
129    // The "resolved" row needs only lastResolved: the guide may be gone by now (a refresh found no state).
130    if (!e.props.hasSurvey && last !== null) {
131      const { Box, Button, Text } = $.ui.resolve(e)
132      const unresolve = argvOf(last.unresolve)
133      const root = last.root || g?.root || ''
134      return (
135        <Box flexDirection="row" flexWrap="wrap" gap={1}>
136          <Text>{`✓ resolved: ${Array.from(last.text).slice(0, 90).join('')}`}</Text>
137          {unresolve && !busy ? (
138            <Button
139              key="undo"
140              label="Undo"
141              onPress={async () => {
142                if (!(await claim($))) return
143                try {
144                  await update($, lastResolved, () => null)
145                  const r = await $.process.run(unresolve, root ? { cwd: root } : undefined)
146                  const first = (r.exitCode === 0 ? r.stdout : r.stderr || r.stdout).split('\n').find(l => l.trim()) ?? ''
147                  if (r.exitCode === 0) $.ui.toast(first || 'abofles-guide: undone')
148                  else if (r.exitCode === 3) $.ui.toast('abofles-guide: already open') // nothing changed
149                  else $.ui.toast(`abofles-guide: undo failed — ${first || `exit ${r.exitCode}`}`)
150                  await refresh($)
151                } catch (err) {
152                  $.ui.toast(`abofles-guide: undo failed — ${err instanceof Error ? err.message : String(err)}`)
153                } finally {
154                  await update($, running, () => false)
155                }
156              }}
157            />
158          ) : null}
159          <Button
160            key="hide"
161            label="Hide"
162            onPress={async () => {
163              // the hooks have not recomputed the ladder yet: keep the band down until its items change,
164              // so the item just resolved does not come back with its Resolve button
165              await update($, lastResolved, () => null)
166              if (g !== null) await update($, hiddenKey, () => hideKeyOf(g))
167            }}
168          />
169        </Box>
170      )
171    }
172    if (e.props.hasSurvey || g === null || hideKeyOf(g) === (await read($, hiddenKey))) {
173      return next(e)
174    }
175    // A value an earlier build kept in $.state survives a reload: every newer field is optional here too.
176    const items = Array.isArray(g.items) ? g.items : []
177    const top = items[0]
178    // Nothing open: every item is ✅ (an old state file without items: the line's only priority is ✅).
179    const done = items.length > 0 ? items.every(i => i.p === '✅') : keyOf(g.line) === '✅'
180    const memo = needsMemo(g.ctx ?? null)
181    if (done && !memo) {
182      return next(e)
183    }
184    const { Box, Button, Text } = $.ui.resolve(e)
185    const prompt = top?.fill || promptFor(g.line)
186    const recheck = !done && typeof top?.recheck === 'string' && top.recheck.trim() ? top.recheck : null
187    const resolve = !done && !recheck && Array.isArray(top?.resolve) && top.resolve.length > 0 ? top.resolve : null
188    const meter = meterOf(g)
189    const disarm = () => update($, armed, () => null)
190    const hide = async () => {
191      await disarm()
192      await update($, hiddenKey, () => hideKeyOf(g)) // down until the shown items (or the memo mark) change
193    }
194    const topId = top ? idOf(top) : ''
195    // Armed for this very item and not older than ARM_MS (the timer is gone after a reload, the timestamp is not).
196    const was = resolve ? await read($, armed) : null
197    const isArmed = was !== null && was.id === topId && (await $.clock.now()) - was.ts <= ARM_MS
198    // Put text into the prompt box; keep a draft the person already typed.
199    const fill = async (text: string) => {
200      await disarm()
201      const draft = (await $.prompt.read()).text.trim()
202      const r = await $.prompt.fill({ text: draft ? `\n${text}` : text, mode: draft ? 'append' : 'replace' })
203      if (r.isFilled) await hide()
204      else $.ui.toast('abofles-guide: the prompt box is not open — use Copy')
205    }
206    return (
207      <Box flexDirection="column">
208        {done ? null : <Text>{g.line.replace(/`/g, '')}</Text>}
209        {meter ? <Text dimColor>{meter}</Text> : null}
210        <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
211          {done ? null : (
212            <Button key="use" label="Use" onPress={() => fill(prompt)} />
213          )}
214          {recheck ? <Button key="recheck" label="Re-check" onPress={() => fill(recheck)} /> : null}
215          {resolve && !busy ? (
216            <Button
217              key="resolve"
218              label={isArmed ? 'Confirm resolve' : 'Resolve'}
219              onPress={async () => {
220                const now = await $.clock.now()
221                const armedNow = await read($, armed)
222                if (!armedNow || armedNow.id !== topId || now - armedNow.ts > ARM_MS) {
223                  await update($, armed, () => ({ id: topId, ts: now }))
224                  // the label goes back to Resolve when the 5 s pass, so it never reads "Confirm" for a press that only re-arms
225                  $.clock.after(ARM_MS, () => {
226                    void update($, armed, cur => (cur && cur.ts === now ? null : cur))
227                  })
228                  return
229                }
230                await disarm()
231                if (!(await claim($))) return
232                try {
233                  const r = await $.process.run(resolve, g.root ? { cwd: g.root } : undefined)
234                  const first = (r.exitCode === 0 ? r.stdout : r.stderr || r.stdout).split('\n').find(l => l.trim()) ?? ''
235                  if (r.exitCode === 0) {
236                    await update($, lastResolved, () => ({ text: top.text, unresolve: top.unresolve ?? null, root: g.root, ts: now }))
237                    $.ui.toast(first || 'abofles-guide: resolved')
238                  } else if (r.exitCode === 3) {
239                    // nothing changed: it was resolved already, so there is nothing to undo
240                    $.ui.toast('abofles-guide: already resolved')
241                  } else {
242                    $.ui.toast(`abofles-guide: resolve failed — ${first || `exit ${r.exitCode}`}`)
243                  }
244                  await refresh($)
245                } catch (err) {
246                  $.ui.toast(`abofles-guide: resolve failed — ${err instanceof Error ? err.message : String(err)}`)
247                } finally {
248                  await update($, running, () => false)
249                }
250              }}
251            />
252          ) : null}
253          {memo ? <Button key="memo" label="Memo" onPress={() => fill('/abofles:memo')} /> : null}
254          {done ? null : (
255            <Button
256              key="copy"
257              label="Copy"
258              onPress={async press => {
259                await disarm()
260                const r = await $.ui.copy({ text: prompt, surface: press.surface })
261                $.ui.toast(r.isCopied ? 'Guide copied — paste it in the prompt box' : `abofles-guide: copy failed (${r.reason})`)
262              }}
263            />
264          )}
265          <Button key="hide" label="Hide" onPress={hide} />
266        </Box>
267      </Box>
268    )
269  })
270}
271
272// The memo is due when the context is within 50K tokens of its next mark, or past the last mark (mark null).
273function needsMemo(ctx: Guide['ctx']): boolean {
274  if (!ctx) return false
275  return ctx.mark == null || ctx.mark * 1000 - ctx.tokens <= 50000
276}
277
278// The dim second line: `ctx 412K / 500K · task 3 spawns · $0.41 · budget 2 left · suggest Opus 5.5 · Medium`.
279function meterOf(g: Guide): string {
280  const parts: string[] = []
281  if (g.ctx) parts.push(g.ctx.mark == null ? `ctx ${Math.round(g.ctx.tokens / 1000)}K` : `ctx ${Math.round(g.ctx.tokens / 1000)}K / ${g.ctx.mark}K`)
282  if (g.task) {
283    parts.push(`task ${g.task.spawns} spawns`, `$${g.task.usd.toFixed(2)}`)
284    if (g.task.left != null) parts.push(`budget ${g.task.left} left`)
285  }
286  const main = g.main?.replace(/^\s*main:\s*/i, '').trim()
287  if (main) parts.push(main)
288  return parts.join(' · ')
289}
290
291// The ladder's priorities in order (🔴🟡, ✅, …): the fallback identity of a state file from before item ids.
292function keyOf(line: string): string {
293  return (line.match(/[🔴🟠🟡⏳🧠💰✅]/gu) ?? []).join('') || line
294}
295
296// What Hide compares: the ids of the shown items, plus the memo mark when the memo is due. A hidden band comes back
297// when an item appears or goes, or when a newly crossed mark makes the memo due again; not when only wording changes.
298function hideKeyOf(g: Guide): string {
299  const items = Array.isArray(g.items) ? g.items : []
300  const ids = items.length > 0 ? items.map(idOf).join('|') : keyOf(g.line)
301  return needsMemo(g.ctx ?? null) ? `${ids}|memo:${g.ctx?.mark ?? 'past'}` : ids
302}
303
304// The prompt the Use and Copy buttons hand over: the guide line as an instruction the assistant acts on.
305function promptFor(line: string): string {
306  const body = line.replace(/^\s*🧭\s*/, '').trim()
307  return `abofles guide — do this now, in order: ${body}`
308}
309
types/index.d.ts 39 lines
1export type GuideItem = {
2  id?: string // stable per underlying thing (fail:<agent>, dec:<n>, ctx:<mark>, …); absent in older state files: text stands in
3  p: string // the priority emoji (🔴🟠🟡⏳🧠💰✅)
4  text: string
5  fill: string // what Use puts in the prompt box and Copy copies
6  resolve: string[] | null // argv Resolve runs (cwd = Guide.root), or null
7  recheck?: string | null // 🔴 items: the prompt Re-check puts in the prompt box (no Resolve then)
8  unresolve?: string[] | null // argv Undo runs (cwd = Guide.root) after a successful Resolve, or null
9}
10
11export type Guide = {
12  line: string
13  ts: number
14  root: string
15  items: GuideItem[]
16  ctx: { tokens: number; mark: number | null } | null // mark in K tokens; null: past the last mark
17  task: { spawns: number; usd: number; budget: number | null; left: number | null } | null
18  main: string | null // "suggest Opus 5.5 · Medium (now Fable 5.1 · Medium)"; null when the main session fits
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    // `hiddenKey`: the ids of the items shown when the person hid the band (Hide or Use), plus the memo mark when the
24    // memo was due; the band stays down while those are the same, and comes back when an item appears or goes.
25    // `armed`: the item (by id) whose Resolve was pressed once, with the clock time; the second press within 5 s runs it.
26    // `lastResolved`: the last successful Resolve, kept for Undo until the next turn completes.
27    // `running`: true while a Resolve or Undo command runs; both buttons are gone meanwhile.
28    // `toasted`: the ids of the red items already toasted; a toast fires only for a red id not in it.
29    'abofles-guide': {
30      guide: Guide | null
31      hiddenKey: string | null
32      armed: { id: string; ts: number } | null
33      lastResolved: { text: string; unresolve: string[] | null; root?: string; ts: number } | null
34      running: boolean
35      toasted: string[]
36    }
37  }
38}
39