SLOPSHOPPER

usage-feed

Sends your Claude Code plan usage and each session's state to upMonitor, over a socket on your Mac.

newnetworktimer
v1.0.0MITupdated 2026-10-25halfmarble/mods/plugins/usage-feed
A shopper browsing a rack in a slop shop
README

usage-feed

A Claude Code mod that tells upMonitor (the paid macOS menu-bar app) how much of your Claude Code plan you have used and what each of your running sessions is doing. upMonitor shows the 5-hour and weekly limits and one bar per session, and a session's state: busy, ask (it waits for your answer), idle, or handoff (idle, and past 250,000 tokens of context or 16 hours: time for a new session).

Install

In a Claude Code session in a terminal:

/plugin install usage-feed --marketplace halfmarble/mods

From a shell:

claude plugin install usage-feed --marketplace halfmarble/mods

In the Claude desktop app: click + next to the prompt box, then Plugins, Add marketplace with halfmarble/mods, then Add plugin.

If the install says that 1 userConfig option is not yet set, that is extraBundleIds (Options, below): it is empty by default and can be left so.

New sessions send from their start; a session that is already running starts after /reload-plugins. To remove the mod: claude plugin uninstall usage-feed@halfmarble.

The mod does not run when Claude Code starts with --safe-mode or when its settings turn off all hooks.

What it sends

Only to upMonitor, on your Mac, through a Unix socket inside upMonitor's own folder (~/Library/Containers/com.halfmarblellc.upmonitor/Data/m.sock). Nothing leaves your Mac and nothing is written to disk. When upMonitor is not running the mod's attempt is refused at once and costs next to nothing.

For each session, about every 10 seconds and at each change:

  • the session id, its name (which Claude Code may make from your first prompt), and the last part of its project folder (not the full path);
  • the model id, the effort level, the context window use (tokens, window, percent), the cost so far and the time since the session started;
  • the plan's 5-hour and weekly usage and when each resets;
  • Claude Code's version;
  • the session's state (busy, waiting, idle) and that of each subagent, by the subagent's id (an id that is not 1 to 64 letters and digits is sent as a hash), each with the second it began;
  • whether the session is interactive (a claude -p run is not);
  • the mod's version, the message format's version, a sequence number and the time the message was made.

It sends no prompt text, no tool input, no transcript and no file contents. When a session ends it sends one message saying so.

The mod only observes: it passes every event on as it received it and never answers a permission or changes a prompt. It reads and writes no file, and calls only $.session, $.http, $.clock, $.state and $.env.

Options

extraBundleIds: a comma-separated list of other upMonitor builds' bundle IDs to feed as well, four at most, each made of letters, digits, dots and dashes. Empty by default; the release app's ID is always fed. Set it at install:

claude plugin install usage-feed --marketplace halfmarble/mods --config extraBundleIds=com.example.build

Development

claude plugin validate --profile directory . and claude plugin test . run in this folder. The pure part is hooks/model.ts; hooks/register.ts holds the hooks and the sender.

License

MIT, Copyright (c) 2026 Halfmarble LLC.

Source 3 files
hooks/register.ts 278 lines
1// usage-feed: keeps each Claude Code session's state from its events and sends it, with the plan's usage, to
2// upMonitor over a Unix socket inside upMonitor's own folder. Every hook only observes: it records what it saw and
3// returns what the hooks beneath returned. The classic hooks carry a `.catch` that only passes the event on, so one
4// that fails (or overruns its time) leaves the event as the hooks beneath made it; the others are not gating.
5import type { Register, EngineInterface } from 'claude-code'
6import type { UsageFeedSnap } from '../types'
7import {
8  HEARTBEAT_MS,
9  SEND_TIMEOUT_MS,
10  afterClear,
11  applyWord,
12  buildEnd,
13  buildRecord,
14  buildUpdate,
15  candidateIds,
16  emptySnap,
17  socketPath,
18  type Word,
19} from './model'
20
21const SNAP = { plugin: 'usage-feed', key: 'snap' } as const
22
23type Step = (snap: UsageFeedSnap, nowS: number) => UsageFeedSnap | null
24type Result = { status?: number; error?: string }
25
26const agentOf = (e: unknown): string | undefined => {
27  const a = (e as { agent_id?: unknown }).agent_id
28  return typeof a === 'string' && a !== '' ? a : undefined
29}
30
31// The main thread's effort level from an event that carries it
32const effortStep = (e: unknown): Step => snap => {
33  if (agentOf(e)) return null
34  const level = (e as { effort?: { level?: unknown } }).effort?.level
35  if (typeof level !== 'string' || level === snap.effort) return null
36  return { ...snap, effort: level }
37}
38
39const wordStep = (agentId: string | undefined, word: Word | null): Step => (snap, nowS) => applyWord(snap, agentId, word, nowS)
40
41const titleStep = (title: unknown): Step => snap => (typeof title === 'string' && title !== '' && title !== snap.title ? { ...snap, title } : null)
42
43// The first step that changes the snapshot carries on from its result; null when none changes it
44const steps = (...all: Step[]): Step => (snap, nowS) => {
45  let cur = snap
46  let changed = false
47  for (const s of all) {
48    const n = s(cur, nowS)
49    if (n !== null) {
50      cur = n
51      changed = true
52    }
53  }
54  return changed ? cur : null
55}
56
57// One sender per Claude Code process. Its state is made by `register`, so a test that loads the mod twice has two.
58type Ctx = {
59  candidates: string[]
60  inFlight: boolean
61  pending: boolean
62  closed: boolean
63  timer: { cancel: () => void } | null
64  tail: Promise<unknown>
65}
66
67// Read-modify-write of the snapshot one at a time: hooks run side by side
68const serial = <T>(ctx: Ctx, fn: () => Promise<T>): Promise<T> => {
69  const run = ctx.tail.then(fn)
70  ctx.tail = run.then(
71    () => undefined,
72    () => undefined,
73  )
74  return run
75}
76
77const getSnap = async ($: EngineInterface): Promise<UsageFeedSnap> => (await $.state.get(SNAP)).value ?? emptySnap()
78
79const update = ($: EngineInterface, ctx: Ctx, step: Step): Promise<boolean> =>
80  serial(ctx, async () => {
81    const next = step(await getSnap($), Math.floor((await $.clock.now()) / 1000))
82    if (next === null) return false
83    await $.state.set(SNAP, next)
84    return true
85  })
86
87// One POST to one app, never rejecting. The host gives up on a listener that accepts and never answers only after
88// 30 s and HttpInit has no timeout, so the fetch is raced against 2 s and the late answer dropped.
89const post = async ($: EngineInterface, home: string, bundleId: string, text: string): Promise<Result> => {
90  try {
91    const fetched: Promise<Result> = $.http
92      .fetch('http://localhost/v1/session', {
93        method: 'POST',
94        headers: { 'content-type': 'application/json' },
95        body: text,
96        socketPath: socketPath(home, bundleId),
97      })
98      .then(
99        r => ({ status: r.status }),
100        (err: unknown) => ({ error: String(err).slice(0, 200) }),
101      )
102    return await new Promise<Result>(resolve => {
103      const t = $.clock.after(SEND_TIMEOUT_MS, () => resolve({ error: 'timeout' }))
104      void fetched.then(r => {
105        t.cancel()
106        resolve(r)
107      })
108    })
109  } catch (err) {
110    return { error: String(err).slice(0, 200) }
111  }
112}
113
114// The next sequence number, kept in the snapshot
115const bump = ($: EngineInterface, ctx: Ctx): Promise<UsageFeedSnap> =>
116  serial(ctx, async () => {
117    const snap = await getSnap($)
118    const next = { ...snap, seq: snap.seq + 1 }
119    await $.state.set(SNAP, next)
120    return next
121  })
122
123const sendAll = async ($: EngineInterface, ctx: Ctx, text: string): Promise<void> => {
124  const home = await $.env.get('HOME')
125  if (typeof home !== 'string' || home === '') return
126  await Promise.all(ctx.candidates.map(id => post($, home, id, text)))
127}
128
129const sendUpdate = async ($: EngineInterface, ctx: Ctx): Promise<void> => {
130  const snap = await bump($, ctx)
131  const nowMs = await $.clock.now()
132  const interactive = snap.interactive ?? (await $.session.surfaces()).length > 0
133  const record = buildRecord({
134    nowMs,
135    sessionId: await $.session.id(),
136    root: await $.session.root(),
137    model: await $.session.model(),
138    version: (await $.session.version()).version,
139    usage: await $.session.usage(),
140    title: snap.title,
141    effort: snap.effort,
142  })
143  await sendAll($, ctx, JSON.stringify(buildUpdate(snap, interactive, record, Math.floor(nowMs / 1000))))
144}
145
146// One request in flight at most; a change during it is sent once more after it, as the latest snapshot
147const kick = ($: EngineInterface, ctx: Ctx): void => {
148  if (ctx.closed) return
149  ctx.pending = true
150  if (ctx.inFlight) return
151  ctx.inFlight = true
152  void (async () => {
153    try {
154      while (ctx.pending && !ctx.closed) {
155        ctx.pending = false
156        await sendUpdate($, ctx)
157      }
158    } catch {
159      // the next change or heartbeat tries again
160    } finally {
161      ctx.inFlight = false
162    }
163  })()
164}
165
166const ensureTimer = ($: EngineInterface, ctx: Ctx): void => {
167  if (ctx.timer !== null || ctx.closed) return
168  ctx.timer = $.clock.every(HEARTBEAT_MS, () => kick($, ctx))
169}
170
171// Run an observer's work: it never throws into the hook, and a change asks for a send
172const observe = async ($: EngineInterface, ctx: Ctx, step: Step | null): Promise<void> => {
173  try {
174    ensureTimer($, ctx)
175    const changed = step === null ? false : await update($, ctx, step)
176    if (changed) kick($, ctx)
177  } catch {
178    // an observer must not fail the event
179  }
180}
181
182export const register: Register = (on, options) => {
183  const ctx: Ctx = { candidates: candidateIds(options.extraBundleIds), inFlight: false, pending: false, closed: false, timer: null, tail: Promise.resolve() }
184
185  on('session.start', async ($, e, next) => {
186    const r = await next(e)
187    try {
188      ctx.closed = false
189      await update($, ctx, snap => (snap.interactive === e.isInteractive ? null : { ...snap, interactive: e.isInteractive }))
190      ensureTimer($, ctx)
191      kick($, ctx)
192    } catch {
193      // as observe
194    }
195    return r
196  })
197  on('classic.SessionStart', async ($, e, next) => {
198    const r = await next(e)
199    await observe($, ctx, titleStep(e.session_title))
200    kick($, ctx)
201    return r
202
203  }).catch((_, e, next) => next(e))
204  on('classic.UserPromptSubmit', async ($, e, next) => {
205    const r = await next(e)
206    await observe($, ctx, steps(titleStep(r.sessionTitle ?? e.session_title), wordStep(agentOf(e), 'busy')))
207    return r
208
209  }).catch((_, e, next) => next(e))
210  on('classic.PostToolUse', async ($, e, next) => {
211    await observe($, ctx, steps(effortStep(e), wordStep(agentOf(e), 'busy')))
212    return next(e)
213
214  }).catch((_, e, next) => next(e))
215  on('classic.PostToolUseFailure', async ($, e, next) => {
216    await observe($, ctx, wordStep(agentOf(e), 'busy'))
217    return next(e)
218
219  }).catch((_, e, next) => next(e))
220  // Before next: the hooks beneath may wait for the person's answer
221  on('classic.PermissionRequest', async ($, e, next) => {
222    await observe($, ctx, steps(effortStep(e), wordStep(agentOf(e), 'waiting')))
223    return next(e)
224
225  }).catch((_, e, next) => next(e))
226  on('classic.Stop', async ($, e, next) => {
227    await observe($, ctx, steps(effortStep(e), agentOf(e) ? () => null : wordStep(undefined, 'idle')))
228    return next(e)
229
230  }).catch((_, e, next) => next(e))
231  on('classic.StopFailure', async ($, e, next) => {
232    await observe($, ctx, agentOf(e) ? null : wordStep(undefined, 'idle'))
233    return next(e)
234
235  }).catch((_, e, next) => next(e))
236  on('classic.SubagentStart', async ($, e, next) => {
237    const a = agentOf(e)
238    await observe($, ctx, a ? wordStep(a, 'busy') : null)
239    return next(e)
240
241  }).catch((_, e, next) => next(e))
242  on('classic.SubagentStop', async ($, e, next) => {
243    const a = agentOf(e)
244    await observe($, ctx, a ? wordStep(a, null) : null)
245    return next(e)
246
247  }).catch((_, e, next) => next(e))
248  // Every turn's end, whatever its reason: main idle, or that agent removed (an Escape on a permission prompt gives
249  // only this event, and a stopped agent gets no SubagentStop)
250  on('turn.complete', async ($, e, next) => {
251    await observe($, ctx, e.agentId ? wordStep(e.agentId, null) : wordStep(undefined, 'idle'))
252    return next(e)
253  })
254  on('session.measure', async ($, e, next) => {
255    await observe($, ctx, null)
256    kick($, ctx)
257    return next(e)
258  })
259  on('session.end', async ($, e, next) => {
260    try {
261      const clearing = e.reason === 'clear' || e.reason === 'resume'
262      ctx.pending = false
263      if (!clearing) {
264        ctx.closed = true
265        ctx.timer?.cancel()
266        ctx.timer = null
267      }
268      const snap = await bump($, ctx)
269      await sendAll($, ctx, JSON.stringify(buildEnd(snap.seq, e.sessionId)))
270      // The process goes on under a new id: idle, no agents, no name, until its events arrive
271      if (clearing) await update($, ctx, (s, nowS) => afterClear(s, nowS))
272    } catch {
273      // the app drops a session whose process has gone
274    }
275    return next(e)
276  })
277}
278
hooks/model.ts 155 lines
1// The pure part of usage-feed: events to a session's state, and state plus usage to the message the sender posts.
2// Nothing here calls the engine, so claude plugin test checks it by plain calls.
3import type { UsageFeedSnap } from '../types'
4
5export const RELEASE_BUNDLE_ID = 'com.halfmarblellc.upmonitor'
6// Keep equal to "version" in .claude-plugin/plugin.json
7export const MOD_VERSION = '1.0.0'
8export const WIRE_VERSION = 1
9export const HEARTBEAT_MS = 10000
10export const SEND_TIMEOUT_MS = 2000
11// A busy subagent whose state is older than this is no longer sent
12export const AGENT_BUSY_MAX_AGE_S = 7200
13export const AGENTS_MAX = 64
14export const EXTRA_IDS_MAX = 4
15
16export type Word = 'busy' | 'waiting' | 'idle'
17
18export const emptySnap = (): UsageFeedSnap => ({ seq: 0, main: null, agents: {}, effort: null, title: null, interactive: null })
19
20// "<word> <epoch seconds>"
21export const wordOf = (line: string | null | undefined): string | undefined => line?.split(' ')[0]
22const epochOf = (line: string): number => {
23  const n = Number(line.split(' ')[1])
24  return Number.isFinite(n) ? n : 0
25}
26
27// The UTF-8 bytes of a string, without TextEncoder
28const utf8 = (s: string): number[] => {
29  const out: number[] = []
30  for (const ch of s) {
31    const c = ch.codePointAt(0) ?? 0
32    if (c < 0x80) out.push(c)
33    else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f))
34    else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
35    else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
36  }
37  return out
38}
39
40// FNV-1a, 64 bits, as 16 hex digits
41export const fnv1a64 = (s: string): string => {
42  let h = 0xcbf29ce484222325n
43  for (const b of utf8(s)) h = BigInt.asUintN(64, (h ^ BigInt(b)) * 0x100000001b3n)
44  return h.toString(16).padStart(16, '0')
45}
46
47// An agent id the app takes as it is stays; any other (a teammate's "name@team") is sent as "h" and its hash
48export const agentKey = (id: string): string => (/^[A-Za-z0-9]{1,64}$/.test(id) ? id : `h${fnv1a64(id)}`)
49
50// The snapshot after one state change, or null when it changes nothing. word null removes an agent; for the main
51// thread a null word changes nothing.
52export const applyWord = (snap: UsageFeedSnap, agentId: string | undefined, word: Word | null, nowS: number): UsageFeedSnap | null => {
53  const line = word === null ? null : `${word} ${nowS}`
54  if (agentId === undefined || agentId === '') {
55    if (line === null || wordOf(snap.main) === word) return null
56    return { ...snap, main: line }
57  }
58  const key = agentKey(agentId)
59  const agents = { ...snap.agents }
60  if (line === null) {
61    if (!(key in agents)) return null
62    delete agents[key]
63  } else {
64    if (wordOf(agents[key]) === word) return null
65    agents[key] = line
66  }
67  return { ...snap, agents }
68}
69
70// The agents a message carries: no busy one older than two hours, and the 64 with the latest epochs
71export const agentsToSend = (agents: Record<string, string>, nowS: number): Record<string, string> => {
72  const live = Object.entries(agents).filter(([, line]) => !(wordOf(line) === 'busy' && nowS - epochOf(line) > AGENT_BUSY_MAX_AGE_S))
73  live.sort((a, b) => epochOf(b[1]) - epochOf(a[1]) || (a[0] < b[0] ? -1 : 1))
74  return Object.fromEntries(live.slice(0, AGENTS_MAX))
75}
76
77// The bundle IDs to feed: the release ID, then the valid ones of the option (comma-separated, four at most)
78export const candidateIds = (extra: unknown): string[] => {
79  const ids = [RELEASE_BUNDLE_ID]
80  if (typeof extra !== 'string') return ids
81  let n = 0
82  for (const part of extra.split(',')) {
83    const id = part.trim()
84    if (!/^[A-Za-z0-9.-]{1,64}$/.test(id) || ids.includes(id)) continue
85    if (n >= EXTRA_IDS_MAX) break
86    ids.push(id)
87    n += 1
88  }
89  return ids
90}
91
92export const socketPath = (home: string, bundleId: string): string => `${home}/Library/Containers/${bundleId}/Data/m.sock`
93
94export const lastPart = (path: string): string | null => path.split('/').filter(p => p !== '').pop() ?? null
95
96export type UsageIn = {
97  startedAt: number
98  context: { tokens?: number; window: number; percent?: number }
99  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
100  cost?: { usd: number }
101}
102
103export type RecordIn = {
104  nowMs: number
105  sessionId: string
106  root: string
107  model: string
108  version: string
109  usage: UsageIn
110  title: string | null
111  effort: string | null
112}
113
114// the v1 record, in the fields the app reads
115export const buildRecord = (r: RecordIn) => {
116  const limits: Record<string, { used_percentage: number; resets_at: number | null }> = {}
117  for (const l of r.usage.rateLimits) {
118    if (l.kind !== 'five_hour' && l.kind !== 'seven_day') continue
119    const at = l.resetsAt === undefined ? Number.NaN : Date.parse(l.resetsAt)
120    limits[l.kind] = { used_percentage: l.percentUsed, resets_at: Number.isFinite(at) ? Math.floor(at / 1000) : null }
121  }
122  return {
123    v: WIRE_VERSION,
124    written_at: Math.floor(r.nowMs / 1000),
125    session_id: r.sessionId,
126    session_name: r.title,
127    project: lastPart(r.root),
128    model: { id: r.model },
129    effort: { level: r.effort },
130    context_window: {
131      used_percentage: r.usage.context.percent ?? null,
132      context_window_size: r.usage.context.window,
133      used_tokens: r.usage.context.tokens ?? null,
134    },
135    cost: { total_cost_usd: r.usage.cost?.usd ?? null, total_duration_ms: r.nowMs - r.usage.startedAt },
136    ...(Object.keys(limits).length > 0 ? { rate_limits: limits } : {}),
137    version: r.version,
138  }
139}
140
141export const buildUpdate = (snap: UsageFeedSnap, interactive: boolean, record: ReturnType<typeof buildRecord>, nowS: number) => ({
142  v: WIRE_VERSION,
143  kind: 'update' as const,
144  seq: snap.seq,
145  mod: MOD_VERSION,
146  interactive,
147  record,
148  state: { main: snap.main, agents: agentsToSend(snap.agents, nowS) },
149})
150
151export const buildEnd = (seq: number, sessionId: string) => ({ v: WIRE_VERSION, kind: 'end' as const, seq, mod: MOD_VERSION, session_id: sessionId })
152
153// The session's snapshot after /clear or a resume: the same process under a new id, main idle, no agents, no name
154export const afterClear = (snap: UsageFeedSnap, nowS: number): UsageFeedSnap => ({ ...snap, main: `idle ${nowS}`, agents: {}, title: null })
155
types/index.d.ts 20 lines
1// The state this mod keeps in $.state: one snapshot per session, which a reload of the mod keeps.
2export type UsageFeedSnap = {
3  // Counts the messages sent for this session, so a receiver can tell a late one from a newer one
4  seq: number
5  // The main thread's state as "<word> <epoch seconds>", or null until the mod has seen a main-thread event
6  main: string | null
7  // Each subagent's state in the same form, by its id (an id outside [A-Za-z0-9]{1,64} is kept as "h" and 16 hex digits)
8  agents: Record<string, string>
9  effort: string | null
10  title: string | null
11  // The session's isInteractive from session.start, null until the mod has seen it
12  interactive: boolean | null
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'usage-feed': { snap: UsageFeedSnap }
18  }
19}
20