SLOPSHOPPER

weebo-hud

Weebo HUD resume pane: goal, plan, notes and what Claude waits on, kept current by Claude.

newpaneguardcommandtool
v0.1.0Apache-2.0updated 2026-10-26batleforc/weebo-skillsagent/mods/weebo-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · weebo-hud
│ ┃ Reprise ✕ › fix the failing auth test and add an audit log call │ ┃ Aucune note pour cette session. │ ⏺ 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 │ │ › /etat │ ⎿ weebo-hud: Panneau de reprise ouvert │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Reprise
Aucune note pour cette session.
README

weebo-hud

A Claude Code mod: the resume pane, a pane written in French (objectif, plan, notes, en attente) that Claude keeps current, so you can pick a session back up at a glance. /etat toggles it.

The status line under the prompt (folder, model, context, quotas, free RAM, CPU and disk) is not in this mod: a plugin cannot set statusLine. It comes from wska hud statusline, installed by wska hud install.

Install both at user scope:

task skills:install ITEM=weebo-hud SCOPE=user
# or by hand: wska hud install && claude plugin install weebo-hud@weebo --scope user

Full documentation is in docs/content/docs/weebo-hud.md. The design is in the spec.

Source 3 files
hooks/register.tsx 247 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Notes, Todo } from '../types'
5import { isEmpty, loadNotes, mergeNotes, purgeKeys } from './notes'
6
7const notesAtom = atom({ plugin: 'weebo-hud', key: 'notes' } as const, null)
8
9const PANE = { id: 'weebo-hud', title: 'Reprise' }
10const TOOL = 'mcp__weebo-hud__set_status'
11const WARN = '#d7af00' // orange of "En attente"
12
13// Opens the pane unasked, at most once, when it has content and is not already up.
14const autoOpen = async ($: EngineInterface, n: Notes | null, options: PluginOptions, auto: { done: boolean }) => {
15  if (auto.done || options.paneAuto === false || isEmpty(n)) return
16  if ((await $.ui.panes()).some((p) => p.id === PANE.id)) return
17  auto.done = true // once per session: a pane the user closed stays closed until /etat
18  await $.ui.open(PANE)
19}
20
21const keyOf = (id: string) => `session:${id}`
22
23// Main conversation only: a subagent's calls would clobber the pane.
24const fromSubagent = (e: unknown) => !!(e as { agentId?: string }).agentId
25
26const STATUSES = ['pending', 'in_progress', 'completed'] as const
27const asStatus = (v: unknown): Todo['status'] | undefined => STATUSES.find((s) => s === v)
28
29// Per-load state, owned by `register`. `synced` names the session the atom's notes belong to:
30// /clear and an in-REPL /resume change the id without a session.start, so every save re-checks
31// it; `ready` makes parallel saves share one reload.
32type Ctx = { auto: { done: boolean }; synced?: { sid: string; ready: Promise<void> } }
33
34const sync = async ($: EngineInterface, ctx: Ctx) => {
35  const id = await $.session.id()
36  if (ctx.synced?.sid !== id) {
37    ctx.auto.done = false
38    const load = async () => {
39      const mine = loadNotes(await $.store.get(keyOf(id)))
40      await update($, notesAtom, () => mine)
41    }
42    const mine: Ctx['synced'] = {
43      sid: id,
44      ready: load().catch((err) => {
45        if (ctx.synced === mine) ctx.synced = undefined // retry the reload on the next save
46        throw err
47      }),
48    }
49    ctx.synced = mine
50  }
51  await ctx.synced!.ready
52
53  return id
54}
55
56// Merges inside the atom's updater (atomic, so parallel saves keep each other's fields), then
57// persists under the session the notes were loaded for, never another one's key.
58const save = async ($: EngineInterface, ctx: Ctx, patch: Parameters<typeof mergeNotes>[1]) => {
59  const id = await sync($, ctx)
60  const now = await $.clock.now()
61  if (ctx.synced?.sid !== id) throw new Error('session switched mid-save')
62  await update($, notesAtom, (cur) => mergeNotes(cur, patch, now))
63  const latest = await read($, notesAtom)
64  if (ctx.synced?.sid === id && (await $.session.id()) === id) await $.store.set(keyOf(id), latest)
65
66  return latest
67}
68
69// Mirrors a successful tool call into the plan; never alters `ran`, never throws.
70const mirror = async (
71  $: EngineInterface,
72  ctx: Ctx,
73  options: PluginOptions,
74  e: unknown,
75  ran: { deny?: string; isError?: boolean },
76  todos: Parameters<typeof mergeNotes>[1]['todos'],
77) => {
78  if (fromSubagent(e) || ran.deny || ran.isError || !todos) return
79  try {
80    await autoOpen($, await save($, ctx, { todos }), options, ctx.auto)
81  } catch {}
82}
83
84export const register: Register = (on, options) => {
85  const ctx: Ctx = { auto: { done: false } }
86
87  on('session.start', async ($, e, next) => {
88    const started = await next(e)
89
90    // Own try: a registration failure must not stop the purge and load below.
91    // Headless runs (-p, SDK) get no tool and no command: nobody sees the pane.
92    if (e.isInteractive) {
93      try {
94        await $.tool.register({
95          name: 'set_status',
96          description:
97            "Update the user's resume pane (weebo-hud). Call it when work starts, after each step completed, and whenever you wait on the user. Main conversation only: subagents must not call it. Each field given replaces the stored one; an empty string clears it. Write in the conversation's language.",
98          inputSchema: {
99            type: 'object',
100            properties: {
101              objectif: { type: 'string', description: 'One-line goal of the current work.' },
102              notes: { type: 'string', description: 'Where things stand and the decisions made.' },
103              attente: { type: 'string', description: 'What you are waiting on from the user; empty string clears.' },
104            },
105            additionalProperties: false,
106          },
107          isDeferred: false,
108        })
109        await $.command.register({ name: 'etat', description: 'Ouvrir/fermer le panneau de reprise' })
110      } catch {}
111    }
112
113    // Purge and load fail apart: a failing purge must not skip the resume load.
114    try {
115      const now = await $.clock.now()
116      const keys = (await $.store.keys()).filter((k) => k.startsWith('session:'))
117      const entries = await Promise.all(keys.map(async (k): Promise<[string, unknown]> => [k, await $.store.get(k)]))
118      for (const k of purgeKeys(entries, now)) await $.store.delete(k)
119    } catch {}
120    try {
121      ctx.synced = undefined // a start (or reload) always reloads from the store
122      await sync($, ctx)
123      await autoOpen($, await read($, notesAtom), options, ctx.auto)
124    } catch {}
125
126    return started
127  })
128
129  // The next save or render re-syncs to the new session.
130  on('session.end', async ($, e, next) => {
131    ctx.synced = undefined
132    ctx.auto.done = false
133
134    return next(e)
135  })
136
137  on('tool.call', { tool: TOOL }, async ($, e) => {
138    if (fromSubagent(e)) return { result: 'ok' }
139    const { objectif, notes, attente } = e as unknown as Record<string, unknown>
140    const pick = (v: unknown) => (typeof v === 'string' ? v : undefined)
141    // The result is 'ok' whatever the pane does after the write.
142    try {
143      const saved = await save($, ctx, { objectif: pick(objectif), notes: pick(notes), attente: pick(attente) })
144      await autoOpen($, saved, options, ctx.auto).catch(() => undefined)
145    } catch {}
146
147    return { result: 'ok' }
148  })
149
150  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
151    const ran = await next(e)
152    const todos = (e as unknown as { todos?: Todo[] }).todos
153    await mirror($, ctx, options, e, ran, Array.isArray(todos) ? todos : undefined)
154
155    return ran
156  })
157
158  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
159    const ran = await next(e)
160    const id = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
161    const { subject, activeForm } = e as unknown as { subject?: unknown; activeForm?: unknown }
162    const add =
163      typeof id === 'string' && typeof subject === 'string'
164        ? (cur: Todo[]) => [
165            ...cur.filter((t) => t.id !== id),
166            { id, content: subject, status: 'pending' as const, activeForm: typeof activeForm === 'string' ? activeForm : '' },
167          ]
168        : undefined
169    await mirror($, ctx, options, e, ran, add)
170
171    return ran
172  })
173
174  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
175    const ran = await next(e)
176    const ok = (ran.result as { success?: unknown } | undefined)?.success === true
177    const { taskId, status, subject, activeForm } = e as unknown as Record<string, unknown>
178    const edit =
179      ok && typeof taskId === 'string'
180        ? (cur: Todo[]) =>
181            status === 'deleted'
182              ? cur.filter((t) => t.id !== taskId)
183              : cur.map((t) =>
184                  t.id !== taskId
185                    ? t
186                    : {
187                        ...t,
188                        status: asStatus(status) ?? t.status,
189                        content: typeof subject === 'string' ? subject : t.content,
190                        activeForm: typeof activeForm === 'string' ? activeForm : t.activeForm,
191                      },
192                )
193        : undefined
194    await mirror($, ctx, options, e, ran, edit)
195
196    return ran
197  })
198
199  on('command.run', { command: 'etat' }, async ($) => {
200    if ((await $.ui.panes()).some((p) => p.id === PANE.id)) {
201      await $.ui.close({ id: PANE.id })
202
203      return { text: 'Panneau de reprise fermé' }
204    }
205    await $.ui.open({ ...PANE, focus: true })
206
207    return { text: 'Panneau de reprise ouvert' }
208  })
209
210  on('ui.render', { component: 'Pane', requestId: PANE.id }, async ($, e) => {
211    const { Box, Text } = $.ui.resolve(e)
212    // Reading the atom subscribes the pane; after a session switch nothing has re-synced it yet,
213    // so draw the current session's stored notes instead (render never writes state).
214    const fromAtom = await read($, notesAtom)
215    const id = await $.session.id().catch(() => undefined)
216    const n =
217      id !== undefined && id !== ctx.synced?.sid
218        ? loadNotes(await $.store.get(keyOf(id)).catch(() => null))
219        : fromAtom
220    const head = (t: string) => <Text bold>{t}</Text>
221
222    return (
223      <Box flexDirection="column" width={e.props.bodyColumns}>
224        {isEmpty(n) || !n ? (
225          <Text dimColor>Aucune note pour cette session.</Text>
226        ) : (
227          <Box flexDirection="column">
228            {n.objectif && head('Objectif')}
229            {n.objectif && <Text>{n.objectif}</Text>}
230            {n.todos.length > 0 && head('Plan')}
231            {n.todos.map((t, i) => (
232              <Text key={String(i)}>
233                {t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▸' : '·'}{' '}
234                {t.status === 'in_progress' ? t.activeForm || t.content : t.content}
235              </Text>
236            ))}
237            {n.notes && head('Notes')}
238            {n.notes && <Text>{n.notes}</Text>}
239            {n.attente && <Text bold color={WARN}>En attente</Text>}
240            {n.attente && <Text color={WARN}>{n.attente}</Text>}
241          </Box>
242        )}
243      </Box>
244    )
245  })
246}
247
hooks/notes.ts 40 lines
1import type { Notes, Todo } from '../types'
2
3const DAY30 = 30 * 24 * 3600 * 1000
4const str = (v: unknown) => (typeof v === 'string' ? v : '')
5
6export const loadNotes = (raw: unknown): Notes | null => {
7  if (!raw || typeof raw !== 'object') return null
8  const r = raw as Record<string, unknown>
9
10  return {
11    objectif: str(r.objectif),
12    notes: str(r.notes),
13    attente: str(r.attente),
14    todos: Array.isArray(r.todos) ? (r.todos as Todo[]) : [],
15    updatedAt: typeof r.updatedAt === 'number' ? r.updatedAt : 0,
16  }
17}
18
19export const mergeNotes = (
20  cur: Notes | null,
21  // A function patch edits the current list (Task* mirror); an array replaces it (TodoWrite).
22  patch: Partial<Pick<Notes, 'objectif' | 'notes' | 'attente'>> & { todos?: Todo[] | ((cur: Todo[]) => Todo[]) },
23  now: number,
24): Notes => {
25  const base = cur ?? { objectif: '', notes: '', attente: '', todos: [], updatedAt: 0 }
26  const next = { ...base, updatedAt: now }
27  for (const k of ['objectif', 'notes', 'attente'] as const) if (typeof patch[k] === 'string') next[k] = patch[k]
28  if (patch.todos) next.todos = typeof patch.todos === 'function' ? patch.todos(base.todos) : patch.todos
29
30  return next
31}
32
33export const isEmpty = (n: Notes | null): boolean =>
34  !n || !(n.objectif || n.notes || n.attente || n.todos.length)
35
36export const purgeKeys = (entries: [string, unknown][], now: number): string[] =>
37  entries
38    .filter(([k, v]) => k.startsWith('session:') && now - (loadNotes(v)?.updatedAt ?? 0) > DAY30)
39    .map(([k]) => k)
40
types/index.d.ts 10 lines
1// `id` only on items mirrored from TaskCreate/TaskUpdate.
2export type Todo = { id?: string; content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm: string }
3export type Notes = { objectif: string; notes: string; attente: string; todos: Todo[]; updatedAt: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'weebo-hud': { notes: Notes | null }
8  }
9}
10