Makes Higgsfield image and video generation easy, just by talking to Claude. Standalone: it calls the Higgsfield REST API directly with your own Higgsfield API…

Higgsfield image and video generation, driven by Claude. Your own Higgsfield key, prompting best practices built in, and a price check before anything spends. Windows and Mac.
Install it from the REVXL catalog. See CHANGELOG.md for what changed in each version.
Higgsfield can make great images and videos, but getting good results means knowing which model to pick, how to write the prompt, and what each run will cost. This plugin handles that for you.
New in v0.1.5: you decide how much each chat may spend, and you can see it.
Where the band does not appear: Cowork, VS Code, WSL sessions, Claude Code older than 2.1.287, or with mods turned off. Your spending is still protected there: until a cap is picked for a chat, the limit of at most $5 per 24 hours applies, and any job over it shows a pop-up with the price (Allow runs that one job, Deny spends nothing).
higgsfield-setup — connect your Higgsfield key, one time.hf-generate — the only skill that spends credits. Prices the job first, then runs it.image-prompting, video-prompting — the prompting craft.static-ads, ugc-video-ads, product-shots, thumbnails — guided flows for common jobs.| Platform | Status |
|---|---|
| Windows | Supported |
| Mac | Supported since v0.1.3: Python 3.9+ (found by setup; Homebrew installs it if missing); the key is kept in the Keychain |
See CHANGELOG.md.
MIT — see LICENSE.
revxl-marketplace — REVXL's curated Claude superengine catalog.
hooks/mod/register.ts 227 lines1import type { Register } from 'claude-code'
2import { SUBMIT_RE, USE_RE, meterColor, ratioOf, esc, pythonOk, logDays, dotGroup, parseJson, jobsFromLog, hhmm,
3 submitStep, bandOnStart, type Job } from './logic'
4
5const PANE = 'hf-details'
6const ASK = 'Higgsfield: how much can Claude spend in this session without asking you each time?'
7const NOT_NOW = 'The client chose not to set a Higgsfield spending cap for this session yet, so nothing was sent. ' +
8 'Ask them before trying again; the cap box will appear again with the next paid job.'
9const BALANCE_ASK = 'What balance does your Higgsfield console show (Billing page)? Type the dollar amount under Other.'
10const MEDIA = /\.(png|jpe?g|webp|gif|mp4|mov|webm)$/i
11
12let st: any = null // last `ledger.py status --session` result
13let err = '' // why the ledger could not be read ('' = fine)
14let used = false // this session touched Higgsfield: show the band
15let jobs: Job[] = []
16let asking: Promise<string | undefined> | null = null // one box for parallel submits ("3 variations")
17let saving: Promise<boolean> | null = null // ... and one save of its answer
18
19async function root($: any): Promise<string> { return await $.session.root() }
20
21async function ledgerRun($: any, args: string[]): Promise<any> {
22 let py = ''
23 try { py = (await $.fs.read((await root($)) + '/higgsfield/.python')).trim() } catch { /* setup not done */ }
24 if (!py) throw new Error('Higgsfield setup is not finished in this folder')
25 if (!pythonOk(py)) throw new Error('higgsfield/.python is not an absolute path to Python; run the setup again')
26 const r = await $.process.run([py, '-B', $.plugin.root + '/scripts/ledger.py', ...args], { timeoutMs: 20000 })
27 const o = parseJson(r.stdout)
28 if (!o) throw new Error('the spend record gave no answer')
29 return o
30}
31
32async function readLogs($: any, entries: any[]): Promise<string[]> {
33 const lines: string[] = []
34 for (const day of logDays(await $.clock.now(), entries)) {
35 try { lines.push(...(await $.fs.read(`${await root($)}/higgsfield/${day}/log.jsonl`)).split(/\r?\n/)) } catch { /* none */ }
36 }
37 return lines
38}
39
40async function refresh($: any) {
41 try {
42 st = await ledgerRun($, ['status', '--session', await $.session.id()])
43 err = st.ok === false ? String(st.message || 'the spend record could not be read') : ''
44 if (!err) jobs = jobsFromLog(await readLogs($, st.session_entries ?? []), st.session_entries ?? [])
45 } catch (x) { err = String((x as Error).message || x) || 'the spend record could not be read' }
46 $.ui.invalidate('ui.render')
47}
48
49/** A reopened chat that already used Higgsfield gets its band back without waiting for the next command.
50 * No higgsfield/.python here: ledgerRun throws before spawning anything, so nothing shows. */
51async function startBand($: any) {
52 await refresh($)
53 if (bandOnStart(st, err)) { used = true; $.ui.invalidate('ui.render') }
54}
55
56/** The figures the client sees: the chat's picked cap, else the 24 h limit (same rule as the guard and the quote). */
57function figures(s: any) {
58 const picked = !!s?.session_cap_set
59 const spent = Number((picked ? s?.session_spent : s?.spent_24h) ?? 0)
60 const cap = Number((picked ? s?.session_cap : s?.cap_usd) ?? 5)
61 const left = Number((picked ? s?.session_headroom : s?.headroom_usd) ?? Math.max(cap - spent, 0))
62 return { picked, spent, cap, left, ratio: ratioOf(spent, cap) }
63}
64
65async function capAnswer($: any): Promise<string | undefined> {
66 if (!asking) asking = $.ui.ask(ASK, { header: 'Session cap', options: ['$5', '$10', '$20', 'Not now'] })
67 .catch(() => undefined).finally(() => { asking = null })
68 return asking
69}
70
71async function setCap($: any, usd: number): Promise<boolean> {
72 try {
73 const o = await ledgerRun($, ['session-cap', 'set', await $.session.id(), usd.toFixed(2)])
74 if (o.ok === false) throw new Error(o.message)
75 } catch {
76 return false
77 }
78 const note = `Higgsfield session cap set to $${usd.toFixed(2)} by the client.`
79 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } }).catch(() => $.ui.log(note))
80 await refresh($)
81 return true
82}
83
84async function pressCap($: any, usd: number) {
85 if (!(await setCap($, usd))) $.ui.toast("Couldn't save the cap, try again")
86}
87
88async function openDetails($: any) {
89 await $.ui.open({ id: PANE, title: 'Higgsfield', focus: true, closeOnEscape: true })
90 await refresh($)
91}
92
93/** Job files are relative to the session root, or absolute; only media files inside the session root open. */
94async function openFile($: any, file: string) {
95 const r = await root($)
96 const abs = /^([A-Za-z]:)?[\\/]/.test(file) ? file : r + '/' + file.replace(/\\/g, '/')
97 const real = (await $.fs.stat(abs, { resolve: true }).catch(() => undefined))?.realPath
98 const base = (await $.fs.stat(r, { resolve: true }).catch(() => undefined))?.realPath?.replace(/[\\/]$/, '')
99 const inside = !!real && !!base && real.startsWith(base) && /[\\/]/.test(real.charAt(base.length))
100 if (!inside || !MEDIA.test(real!)) return void $.ui.toast('Only Higgsfield images and videos in this folder open from here.')
101 try { await $.process.run(/^[A-Za-z]:/.test(real!) ? ['explorer.exe', real!] : ['open', real!]) }
102 catch { $.ui.toast("Couldn't open the file.") }
103}
104
105async function openBilling($: any) { // a fixed URL, never data from the log
106 const url = 'https://open.higgsfield.ai/dashboard'
107 try { await $.process.run(/^[A-Za-z]:/.test(await root($)) ? ['explorer.exe', url] : ['open', url]) }
108 catch { $.ui.toast("Couldn't open the browser.") }
109}
110
111export const register: Register = on => {
112 on('session.start', async ($, e, next) => {
113 const r = await next(e)
114 $.clock.after(0, () => void startBand($).catch(() => {})) // a timer outlives this dispatch; never blocks the start
115 return r
116 })
117
118 // Cap box before the first paid job of the session; refresh after anything that touches Higgsfield.
119 on('tool.call', { tool: ['Bash', 'PowerShell'], command: /higgsfield|hf_rest|ledger\.py/i }, async ($, e, next) => {
120 const cmd = String((e as any).command ?? '')
121 if (USE_RE.test(cmd)) used = true
122 if (SUBMIT_RE.test(cmd)) {
123 await refresh($)
124 if (err) $.ui.toast('Higgsfield spend record unavailable: the 24 h limit applies.')
125 const answer = !err && st && !st.session_cap_set ? await capAnswer($) : undefined
126 const step = submitStep(st, err, answer)
127 if (step.kind === 'refuse') return { deny: NOT_NOW }
128 if (step.kind === 'set') {
129 // after the shared box: refresh, and save only if a parallel submit has not saved it (one save, one note)
130 if (!saving) saving = (async () => { await refresh($); return !!st?.session_cap_set || await setCap($, step.usd) })()
131 .finally(() => { saving = null })
132 if (!(await saving)) $.ui.toast("Couldn't save the cap: the 24 h limit applies.")
133 }
134 await refresh($) // the second of two parallel submits sees the cap the first one set
135 }
136 const r = await next(e) // the permission check (guard + Allow box) and the tool run here
137 await refresh($)
138 return r
139 })
140
141 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142 if ((e as any).props?.hasSurvey || !(used || st?.session_cap_set)) return next(e)
143 const { Box, Text, Button, Svg }: any = $.ui.resolve(e)
144 if (err) return Box({ children: [Text({ dimColor: true, children: ['Higgsfield: spend record unavailable (' + err + ')'] })] })
145 const { picked, spent, cap, left, ratio } = figures(st), color = meterColor(ratio), W = 180
146 const bar = `<svg xmlns="http://www.w3.org/2000/svg" width="${W + 22}" height="16" viewBox="0 0 ${W + 22} 16">` +
147 `<path d="M7 1 L8.6 6.4 L14 8 L8.6 9.6 L7 15 L5.4 9.6 L0 8 L5.4 6.4 Z" fill="${color}"/>` +
148 dotGroup(20, 1, W, 14, ratio, color) + `</svg>`
149 const words = picked ? `of $${cap.toFixed(2)} this session · $${left.toFixed(2)} left`
150 : `of $${cap.toFixed(2)} (24 h limit) · you pick this chat's cap with the next paid job`
151 const press = (label: string, usd: number) => Button({ key: label, label, onPress: () => void pressCap($, usd) })
152 return Box({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', children: [
153 Box({ key: 'meter', flexDirection: 'column', children: [
154 Svg ? Svg({ source: bar, alt: `Higgsfield: $${spent.toFixed(2)} ${words}`, width: W + 22, height: 16 })
155 : Text({ children: ['higgsfield'] }),
156 Box({ key: 'line', flexDirection: 'row', columnGap: 1, children: [
157 Text({ bold: true, children: ['$' + spent.toFixed(2)] }),
158 Text({ dimColor: true, children: [words] })] })] }),
159 Box({ key: 'buttons', flexDirection: 'row', columnGap: 1, children: [
160 press('+$5', cap + 5), press('×2', cap * 2), press('Back to $5', 5),
161 Button({ key: 'details', label: 'Details', onPress: () => void openDetails($) })] }),
162 ] })
163 })
164
165 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
166 if ((e as any).requestId !== PANE) return next(e)
167 const { Box, Text, Button, Svg }: any = $.ui.resolve(e)
168 if (err || !st) return Text({ dimColor: true, children: ['Higgsfield: spend record unavailable. ' + err] })
169 const { picked, spent, cap, left, ratio } = figures(st)
170 const ageDays = st.balance_at ? Math.floor(((await $.clock.now()) / 1000 - st.balance_at) / 86400) : null
171 const tile = (k: string, label: string, value: string, sub?: string) => Box({ key: k, flexDirection: 'column', flexGrow: 1,
172 width: '30%', minWidth: 26, borderStyle: 'round', borderColor: '#3f3f46', paddingX: 1, children: [
173 Text({ dimColor: true, children: [label] }), Text({ bold: true, children: [value] }),
174 ...(sub ? [Text({ dimColor: true, children: [sub] })] : [])] })
175 const meter = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="22" viewBox="0 0 460 22">` +
176 dotGroup(0, 2, 460, 20, ratio, meterColor(ratio)) + `</svg>`
177 const byModel: [string, number][] = []
178 for (const j of jobs) { const f = byModel.find(m => m[0] === j.model); f ? (f[1] += j.cost) : byModel.push([j.model, j.cost]) }
179 byModel.sort((a, b) => b[1] - a[1])
180 const top = byModel.slice(0, 6)
181 const max = Math.max(...top.map(m => m[1]), 0.01), RH = 22, CW = 300
182 const chart = `<svg xmlns="http://www.w3.org/2000/svg" width="${CW}" height="${Math.max(top.length, 1) * RH}" ` +
183 `font-family="system-ui,sans-serif" font-size="12">` + top.map(([m, v], i) =>
184 `<text x="0" y="${i * RH + 14}" fill="#e4e4e7">${esc(m)}</text>` + dotGroup(100, i * RH + 5, 150, 9, v / max, '#22c55e', false) +
185 `<text x="${CW}" y="${i * RH + 14}" fill="#a1a1aa" text-anchor="end">$${v.toFixed(2)}</text>`).join('') + `</svg>`
186 const dot: any = { done: '#22c55e', running: '#f59e0b', refunded: '#9ca3af', charged: '#a1a1aa' }
187 const cell = (k: string, w: string, ...children: any[]) => Box({ key: k, width: w, flexDirection: 'row', columnGap: 1, children })
188 const statusDot = (s: string) => Svg ? [Svg({ source: `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" ` +
189 `viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" fill="${dot[s] ?? '#a1a1aa'}"/></svg>`, alt: s, width: 10, height: 10 })] : []
190 const updateBalance = async () => {
191 let a: string | undefined
192 try { a = await $.ui.ask(BALANCE_ASK, { header: 'Balance', options: ['Not now', 'Open Higgsfield'] }) } catch { return }
193 if (a === 'Open Higgsfield') return void openBilling($)
194 const usd = (a ?? '').replace(/[,$\s]/g, '')
195 if (!/^\d+(\.\d+)?$/.test(usd)) return void (a && a !== 'Not now' && $.ui.toast('That did not look like a dollar amount; nothing changed.'))
196 try {
197 const o = await ledgerRun($, ['balance', 'set', usd])
198 if (o.ok === false) throw new Error(o.message)
199 $.ui.log(`Higgsfield balance recorded as $${usd} by the client.`)
200 } catch { $.ui.toast("Couldn't save the balance.") }
201 await refresh($)
202 }
203 return Box({ flexDirection: 'column', gap: 1, children: [
204 Box({ key: 'tiles', flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children: [
205 tile('t1', picked ? 'SPENT THIS SESSION' : 'SPENT, LAST 24 H', `$${spent.toFixed(2)}`),
206 tile('t2', 'LEFT', `$${left.toFixed(2)}`, picked ? `of $${cap.toFixed(2)} session cap` : `of $${cap.toFixed(2)} (24 h limit)`),
207 tile('t3', 'ACCOUNT', st.balance == null ? 'not set' : `≈ $${Number(st.balance).toFixed(2)}`,
208 ageDays == null ? 'press Update balance' : ageDays === 0 ? 'updated today' : `updated ${ageDays} days ago`)] }),
209 Svg ? Svg({ source: meter, alt: `${picked ? 'Session' : '24 h limit'} ${Math.round(ratio * 100)}% used`, width: 460, height: 22 }) : Text({ children: [''] }),
210 Box({ key: 'meta', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', children: [
211 Text({ dimColor: true, children: [`Last 24 h $${Number(st.spent_24h).toFixed(2)} · Last 7 days $${Number(st.spent_7d).toFixed(2)}`] }),
212 Button({ key: 'upd', label: 'Update balance', onPress: () => void updateBalance() })] }),
213 Text({ bold: true, children: ['Jobs this session'] }),
214 Box({ key: 'jobs', flexDirection: 'column', children: jobs.length ? jobs.map((j, i) => Box({ key: 'j' + i, flexDirection: 'row', alignItems: 'center', children: [
215 cell('t', '20%', Text({ dimColor: true, children: [hhmm(j.time)] })),
216 cell('m', '36%', Text({ children: [`${j.model} · ${j.kind}`] })),
217 cell('c', '14%', Text({ children: ['$' + j.cost.toFixed(2)] })),
218 cell('s', '18%', ...statusDot(j.status), Text({ children: [j.status] })),
219 cell('o', '12%', j.files.length ? Button({ key: 'open' + i, label: 'Open', onPress: () => void openFile($, j.files[0]!) }) : Text({ children: [''] }))] }))
220 : [Text({ dimColor: true, children: ['No paid jobs in this session yet.'] })] }),
221 Text({ bold: true, children: ['Spend by model'] }),
222 top.length && Svg ? Svg({ source: chart, alt: top.map(([m, v]) => `${m} $${v.toFixed(2)}`).join(', '), width: CW, height: top.length * RH })
223 : Text({ dimColor: true, children: ['Nothing yet.'] }),
224 ] })
225 })
226}
227hooks/mod/logic.ts 144 lines1// Pure helpers for the higgsfield-superengine spend mod: no engine calls, so claude plugin test can check them.
2
3export const SUBMIT_RE = /hf_rest\.py["']?\s+submit\b/i
4/** A command that uses Higgsfield (price, generate, fetch): it shows the band. */
5export const USE_RE = /hf_rest\.py["']?\s+(estimate|submit|wait)\b/i
6
7export type Job = { time: number; model: string; kind: 'video' | 'image'; cost: number
8 status: 'running' | 'done' | 'refunded' | 'charged'; files: string[] }
9
10/** The cap a cap-box answer means, or null for Not now / closed / anything that is not a sane dollar amount. */
11export function capFromAnswer(a: string | undefined): number | null {
12 const m = /^\s*\$?\s*(\d{1,4}(?:\.\d{1,2})?)\s*$/.exec(a ?? '')
13 if (!m) return null
14 const n = Number(m[1])
15 return n > 0 && n <= 1000 ? n : null
16}
17
18export function meterColor(ratio: number): string {
19 return ratio >= 0.9 ? '#ef4444' : ratio >= 0.7 ? '#f59e0b' : '#22c55e'
20}
21
22/** Share of the cap spent, 0..1; 0 when there is no usable cap (no NaN on the meter). */
23export function ratioOf(spent: number, cap: number): number {
24 return cap > 0 && spent > 0 ? Math.min(spent / cap, 1) : 0
25}
26
27/** Text made safe inside an SVG <text> element. */
28export function esc(s: string): string {
29 return s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' } as Record<string, string>)[c]!)
30}
31
32/** higgsfield/.python is run as argv[0] only if it is an absolute path to a python (or py) interpreter. */
33export function pythonOk(p: string): boolean {
34 return /^([A-Za-z]:[\\/]|\/|\\\\)/.test(p) && /^(python3?(\.\d+)?|py)(\.exe)?$/i.test(p.split(/[\\/]/).pop() ?? '')
35}
36
37/** Local YYYY-MM-DD log folders, oldest first: from the oldest entry's day to today, at most 7 days back. */
38export function logDays(nowMs: number, entries: any[]): string[] {
39 const now = new Date(nowMs), days: string[] = []
40 const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
41 const first = ymd(new Date(Math.min(nowMs, ...entries.map(e => Number(e?.created_at) * 1000).filter(Number.isFinite))))
42 for (let back = 0; back <= 7; back++) {
43 days.unshift(ymd(new Date(now.getFullYear(), now.getMonth(), now.getDate() - back))) // calendar days: DST-safe
44 if (days[0]! <= first) break
45 }
46 return days
47}
48
49/** Dot-matrix meter (Claude Code effort-slider look) as SVG elements: empty dots light grey, filled dots brighten
50 * toward a white handle at the fill point. */
51export function dotGroup(x: number, y: number, w: number, h: number, ratio: number, color: string, handle = true): string {
52 const cell = h >= 18 ? 4 : 3, dot = cell - 1
53 const cols = Math.floor(w / cell), rows = Math.max(1, Math.floor(h / cell))
54 const fill = Math.round(cols * Math.min(Math.max(ratio, 0), 1))
55 let out = ''
56 for (let c = 0; c < cols; c++) {
57 for (let r = 0; r < rows; r++) {
58 const jitter = 0.55 + 0.45 * (((c * 31 + r * 17) % 7) / 6)
59 const cx = x + c * cell, cy = y + r * cell
60 out += c < fill
61 ? `<rect x="${cx}" y="${cy}" width="${dot}" height="${dot}" rx="${dot / 2}" fill="${color}" fill-opacity="${((0.25 + 0.75 * ((c + 1) / Math.max(fill, 1))) * jitter).toFixed(2)}"/>`
62 : `<rect x="${cx}" y="${cy}" width="${dot}" height="${dot}" rx="${dot / 2}" fill="#a1a1aa" fill-opacity="${(0.32 * jitter).toFixed(2)}"/>`
63 }
64 }
65 if (handle && fill > 0) {
66 const hw = Math.max(cell * 2, 6), hx = Math.min(x + fill * cell - hw / 2, x + w - hw)
67 out += `<rect x="${hx}" y="${y - 1}" width="${hw}" height="${rows * cell + 1}" rx="${hw / 2.4}" fill="#ffffff"/>`
68 }
69 return out
70}
71
72export function parseJson(stdout: string): any | null {
73 for (const line of stdout.split(/\r?\n/).reverse()) {
74 const t = line.trim()
75 if (t.startsWith('{')) { try { return JSON.parse(t) } catch { /* keep looking */ } }
76 }
77 return null
78}
79
80const MODELS: [RegExp, string][] = [[/seedance/i, 'Seedance'], [/kling/i, 'Kling'], [/flare/i, 'Flare'],
81 [/soul/i, 'Soul'], [/z-image/i, 'Z-Image'], [/hailuo|minimax/i, 'Hailuo'], [/wan/i, 'Wan'],
82 [/genjutsu/i, 'Genjutsu'], [/ltx|lightricks/i, 'LTX'], [/grok/i, 'Grok'], [/qwen|alibaba/i, 'Qwen']]
83const VIDEO = /video|seedance|kling|wan|hailuo|genjutsu|ltx|veo/i
84
85export function modelOf(ep: string): string {
86 return (MODELS.find(([re]) => re.test(ep)) ?? [null, ep.split('/').slice(-2).join(' ')])[1] as string
87}
88
89export function kindOf(ep: string): 'video' | 'image' {
90 return VIDEO.test(ep) ? 'video' : 'image'
91}
92
93const STATUS: Record<string, Job['status']> = { pending: 'running', settled: 'done', refunded: 'refunded',
94 assumed_charged: 'charged' }
95
96/** One row per ledger entry of this session, joined with the workspace log by estimate key and request id. The same
97 * request run again has the same key: its entries pair with its submit lines in order (first entry <-> first submit).
98 * An entry refunded as not_sent never wrote a submit line, so it takes no submit (its row has no files). */
99export function jobsFromLog(logLines: string[], entries: any[]): Job[] {
100 const submits = new Map<string, any[]>(), files = new Map<string, string[]>(), taken = new Map<string, number>()
101 for (const l of logLines) {
102 let o: any
103 try { o = JSON.parse(l) } catch { continue }
104 if (o?.event === 'submit' && o.estimate_key) submits.set(o.estimate_key, [...(submits.get(o.estimate_key) ?? []), o])
105 if (o?.event === 'download' && o.request_id && Array.isArray(o.files))
106 files.set(o.request_id, o.files.map((f: any) => typeof f === 'string' ? f
107 : (f && (f.result === 'saved' || f.result === 'exists') && typeof f.path === 'string') ? f.path : null)
108 .filter(Boolean))
109 }
110 return [...entries].sort((a, b) => a.created_at - b.created_at).map(e => {
111 const unsent = e.state === 'refunded' && e.reason === 'not_sent'
112 const n = taken.get(e.id) ?? 0
113 if (!unsent) taken.set(e.id, n + 1)
114 const s = unsent ? {} : submits.get(e.id)?.[n] ?? {}
115 const ep = String(s.endpoint ?? '')
116 return { time: e.created_at, model: ep ? modelOf(ep) : 'Higgsfield', kind: kindOf(ep),
117 cost: e.state === 'pending' ? e.reserved : (e.actual ?? 0), status: STATUS[e.state] ?? 'running',
118 files: (s.request_id && files.get(s.request_id)) || [] }
119 }).sort((a, b) => b.time - a.time)
120}
121
122/** H:MM local time from epoch seconds (no Intl: its presence in the mod runtime is unproven). */
123export function hhmm(t: number): string {
124 const d = new Date(t * 1000)
125 return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
126}
127
128/** Show the band when a chat opens or reopens: only if its spend record says this chat already used Higgsfield
129 * (a picked cap, or money spent). An unreadable record, or a folder without Higgsfield, stays silent. */
130export function bandOnStart(status: any, err: string): boolean {
131 return !err && !!status && status.ok !== false && (!!status.session_cap_set || Number(status.session_spent) > 0)
132}
133
134export type Step = { kind: 'pass' } | { kind: 'set'; usd: number } | { kind: 'refuse' }
135
136/** What the mod does with a paid submit. It refuses only on the person's explicit Not now; a closed box, no
137 * window, junk, or an unreadable ledger all pass to the guard, whose 24 h rule (and ledger checks) decide. */
138export function submitStep(status: any, err: string, answer: string | undefined): Step {
139 if (err || !status || status.session_cap_set) return { kind: 'pass' }
140 if (answer === 'Not now') return { kind: 'refuse' }
141 const usd = capFromAnswer(answer)
142 return usd === null ? { kind: 'pass' } : { kind: 'set', usd }
143}
144