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.

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.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.
hooks/register.tsx 309 lines1import { 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}
309types/index.d.ts 39 lines1export 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