SLOPSHOPPER

hiddenwars

HNDLR uplink — monitor your HiddenWars operator stats and notifications from Claude Code

newpanecommandtoaststatusnetwork
v0.4.2no licenseupdated 2026-10-09snickers54/hndlr-uplink/mods/hiddenwars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hiddenwars
› fix the failing auth test and add an audit log call ⏺ 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 › /hw ⎿ hiddenwars: No uplink session. Run /hw login first (or /hw help). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

hiddenwars — HNDLR uplink for Claude Code

The signal was always there. You just needed a terminal that listens.

A Claude Code mod for HiddenWars operators: your account, piped straight into the terminal you already live in. Check crypto and heat between commits, watch the wire for incoming heat lockouts and raids — without opening the game.

What it does

  • /hw — opens the dashboard panel (a right sidebar in wide terminals, 144+ columns): a status title bar (LIVE/NO LINK), a stat card with your resources, vault, heat gauge and botnet rollup, active operations (world boss, siege, events), recent transmissions, and the latest Wire headlines — with r refresh / m read-all / x close controls (hotkeys work while the panel has keyboard focus — click it or Tab into it). Closing it from the prompt: /hw close, Esc, or ctrl+x x. It auto-refreshes on the poll cadence, and launches by itself the moment a login completes.
  • /hw status — the same operator readout as text.
  • /hw notif [n] — latest transmissions, severity-marked, newest first.
  • /hw read — clear the unread badge.
  • Status line — once logged in, a quiet ◤HW <operator> · crypto 1.2M · heat 22 · 3 unread line lives under your prompt and refreshes itself.
  • Live toasts — new warning/danger transmissions (heat lockouts, bounties posted on you, raids) surface as ◤ HNDLR ◢ toasts the moment they land.
  • /hw login / /hw logout / /hw poll <sec|off> / /hw api <url> — session and background polling controls.

Install

Requires Claude Code v2.1.287+.

claude plugin marketplace add snickers54/hndlr-uplink
claude plugin install hiddenwars@shadownet

Then restart Claude Code (or run /plugin) and type /hw.

Login

Run /hw login — the mod prints a pairing URL and code. Open the link in your browser, authorize the terminal from the operator account you're already logged into, and the uplink connects itself: the dashboard panel opens as soon as the link lands. No password (and no 2FA code) is ever typed in the terminal; the browser session vouches for you. Pairing codes expire after 10 minutes — /hw login cancel aborts early.

The legacy password path is still there for edge cases: /hw login pass opens the email/password pane (with a 2FA step when enabled), or set HIDDENWARS_PASSWORD in the environment once and run /hw login pass you@example.com. The password is read once, never written anywhere, and you'll be reminded to unset it.

Privacy & security

  • Credentials go to the HiddenWars API only (https://api.hiddenwars.io by default; check any time with /hw api).
  • Your password is never stored. The mod keeps a refresh token (rotated by the server on every use, 30-day life) in Claude Code's per-plugin store under ~/.claude/plugins/store/. /hw logout revokes it server-side and erases it locally.
  • The uplink is read-only: it never acts in the game. The only writes are the login endpoints and an explicit /hw read. It polls at most once per 30s (/hw poll to change, /hw poll off to stop).
  • Requests identify themselves as hiddenwars-uplink/0.4 (claude-code-mod).

Development

claude plugin validate ./mods/hiddenwars   # static analysis
node --test mods/hiddenwars/tests/*.test.js
claude --plugin-dir ./mods/hiddenwars      # live load, hot reload

Layout: hooks/register.js (commands, pane, poller) · hooks/api.js (token lifecycle, $-free and unit-tested) · hooks/format.js (pure formatting) · .claude-plugin/marketplace.json at the repo root ships this directory as the shadownet marketplace.

The wire is quiet. It won't stay that way. ◤

Source 4 files
hooks/register.js 622 lines
1// HNDLR uplink — a HiddenWars relay for Claude Code.
2//
3// Registers /hw: player stats, notifications, and a background uplink that
4// keeps a status line under the prompt and toasts incoming alert-grade
5// transmissions. Read-only against the game API (the only writes are the
6// auth endpoints and an explicit `/hw read`).
7
8import { createClient, DEFAULT_BASE } from './api.js'
9import { buildPanel } from './panel.js'
10import { compact, timeAgo, severityMark, row } from './format.js'
11
12const DEFAULT_POLL_SEC = 60
13const MIN_POLL_SEC = 30
14const MAX_POLL_SEC = 3600
15const SEEN_CAP = 200
16
17let pollTimer = null
18let failStreak = 0
19let baseCache = DEFAULT_BASE
20let loginFlow = null // { step, email, password, totpSession, error, prefill }
21let deviceFlow = null // { timer, deviceCode, expiresAt, intervalMs }
22let panelState = null // { timer, data: {state, notif, botnet, wire, error, updatedAt} }
23
24export function register(on) {
25  on('session.start', async ($, e, next) => {
26    const base = await $.store.get('hw.base')
27    if (typeof base === 'string' && base) baseCache = base
28    for (const spec of [
29      { name: 'hw', description: 'HiddenWars uplink — dashboard panel, operator stats & HNDLR notifications', argumentHint: '[panel|close|status|notif [n]|read|login [pass|cancel]|logout|poll <sec|off>|api <url>]' },
30      { name: 'hiddenwars', description: 'HiddenWars uplink (alias of /hw)', argumentHint: '[panel|close|status|notif [n]|read|login [pass|cancel]|logout|poll <sec|off>|api <url>]' },
31    ]) {
32      try {
33        await $.command.register(spec)
34      } catch {
35        // Name taken by another plugin; the other alias still works.
36      }
37    }
38    await startPolling($)
39    return next(e)
40  })
41
42  on('command.run', { command: 'hw' }, ($, e) =>
43    respond($, e).catch((err) => ({ text: '◤ HW uplink error — ' + ((err && err.message) || String(err)) })))
44  on('command.run', { command: 'hiddenwars' }, ($, e) =>
45    respond($, e).catch((err) => ({ text: '◤ HW uplink error — ' + ((err && err.message) || String(err)) })))
46
47  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
48    if (e.requestId === 'hw-panel' && panelState) {
49      const el = $.ui.resolve(e)
50      return buildPanel(el, panelState.data, (e.props && e.props.bodyColumns) || 44)
51    }
52    if (e.requestId !== 'hw-login' || !loginFlow) return next(e)
53    const { Box, Text, Input, Button } = $.ui.resolve(e)
54    const lines = []
55    lines.push(Text({ bold: true, children: ['◤ HNDLR UPLINK — authenticate'] }))
56    lines.push(Text({ dimColor: true, children: ['Credentials go to ' + baseCache + ' only. Fields are drawn as typed.'] }))
57    lines.push(Text({ children: [''] }))
58
59    if (loginFlow.step === 'email') {
60      lines.push(Input({
61        key: 'hw-email', label: 'email', placeholder: 'operator@hiddenwars.io',
62        value: loginFlow.prefill || '', submitLabel: 'next', autoFocus: true,
63        onSubmit: (v) => submitEmail($, v),
64      }))
65    } else if (loginFlow.step === 'password') {
66      lines.push(Text({ children: [loginFlow.email] }))
67      lines.push(Input({
68        key: 'hw-password', label: 'password', submitLabel: 'authenticate', autoFocus: true,
69        onSubmit: (v) => submitPassword($, v),
70      }))
71    } else if (loginFlow.step === 'totp') {
72      lines.push(Text({ children: [loginFlow.email + ' — 2FA enabled'] }))
73      lines.push(Input({
74        key: 'hw-totp', label: 'auth code', placeholder: '6 digits', submitLabel: 'verify', autoFocus: true,
75        onSubmit: (v) => submitTotp($, v),
76      }))
77    } else if (loginFlow.step === 'busy') {
78      lines.push(Text({ dimColor: true, children: ['authenticating…'] }))
79    }
80
81    if (loginFlow.error) {
82      lines.push(Text({ children: [''] }))
83      lines.push(Text({ color: 'red', children: [loginFlow.error] }))
84    }
85    lines.push(Text({ children: [''] }))
86    lines.push(Button({ key: 'hw-cancel', label: 'cancel', onPress: () => cancelLogin($) }))
87    return Box({ flexDirection: 'column', children: lines })
88  })
89
90  // A closed pane must stop its refresh timer; answering without next would
91  // keep the pane open, so pass everything on.
92  on('ui.close', { id: 'hw-panel' }, ($, e, next) => {
93    stopPanelTimer()
94    return next(e)
95  })
96}
97
98async function respond($, e) {
99  return { text: await handleCommand($, String(e.args || '')) }
100}
101
102async function handleCommand($, raw) {
103  const parts = raw.trim().split(/\s+/).filter(Boolean)
104  const sub = (parts[0] || 'panel').toLowerCase()
105  const tokens = await $.store.get('hw.tokens')
106
107  if (sub === 'login') {
108    const mode = (parts[1] || '').toLowerCase()
109    if (mode === 'cancel') {
110      stopDeviceFlow()
111      $.ui.status(undefined)
112      return 'Pairing cancelled.'
113    }
114    if (mode === 'pass' || mode === 'password') return startLogin($, parts[2])
115    return startDeviceLogin($)
116  }
117  if (sub === 'logout') {
118    if (!tokens) {
119      stopDeviceFlow()
120      $.ui.status(undefined)
121      return 'Already logged out.'
122    }
123    stopPolling()
124    stopPanelTimer()
125    stopDeviceFlow()
126    await $.ui.close({ id: 'hw-panel' })
127    $.ui.status(undefined)
128    await client($).logout()
129    return '◤ HNDLR ◢ uplink severed. Run /hw login to reconnect.'
130  }
131  if (sub === 'help') return usage()
132  if (sub === 'api') return setApi($, parts[1])
133  // Pane hotkeys (r/m/x) only fire while the pane holds the keyboard — an
134  // auto-launched pane often opens without it (focus is refused while the
135  // composer has text or a turn runs), so the prompt needs its own close.
136  if (sub === 'close') {
137    stopPanelTimer()
138    await $.ui.close({ id: 'hw-panel' })
139    return 'UPLINK panel closed — /hw reopens it. (Esc or ctrl+x x also close it.)'
140  }
141
142  if (!tokens) return 'No uplink session. Run /hw login first (or /hw help).'
143
144  if (sub === 'poll') return setPoll($, parts[1])
145  if (sub === 'read') {
146    await client($).markAllRead()
147    pollOnce($)
148    return 'All notifications marked read.'
149  }
150  if (sub === 'notif' || sub === 'notifs' || sub === 'notifications') {
151    const n = Math.min(50, Math.max(1, parseInt(parts[1], 10) || 10))
152    return notifText(await client($).get('/player/notifications?limit=' + n))
153  }
154  if (sub === 'panel') return openPanelCommand($)
155  if (sub === 'status') return statusText($)
156  return usage()
157}
158
159function usage() {
160  return [
161    '◤ HNDLR UPLINK — commands',
162    '  /hw              open the dashboard panel (resources, heat, botnet, wire)',
163    '  /hw close        close the panel (also: Esc or ctrl+x x — x works only',
164    '                   while the panel has keyboard focus)',
165    '  /hw status       operator status as text',
166    '  /hw notif [n]    latest n notifications (default 10)',
167    '  /hw read         mark all notifications read',
168    '  /hw login        link this terminal — opens a browser page to authorize',
169    '  /hw login pass [email]  legacy password prompt (never stored)',
170    '  /hw login cancel abort a running pairing',
171    '  /hw logout       sever the uplink',
172    '  /hw poll <sec|off>  background poll cadence (min ' + MIN_POLL_SEC + 's, default ' + DEFAULT_POLL_SEC + 's)',
173    '  /hw api [url|reset] API base (default ' + DEFAULT_BASE + ')',
174  ].join('\n')
175}
176
177// ---- status -----------------------------------------------------------------
178
179async function statusText($) {
180  const c = client($)
181  const [me, notif, botnet] = await Promise.all([
182    c.get('/player'),
183    c.get('/player/notifications?limit=1'),
184    c.get('/botnet/summary').catch(() => null),
185  ])
186  const p = (me && me.player) || {}
187  const out = ['◤ HIDDENWARS UPLINK ─ ' + (p.username || 'operator') + (p.archetype ? ' (' + p.archetype + ')' : ''), '']
188  out.push(row('crypto', compact(p.crypto) + (p.dirty_crypto ? ' (+' + compact(p.dirty_crypto) + ' dirty)' : '')))
189  out.push(row('fragments', compact(p.fragments)))
190  if (p.echoes) out.push(row('echoes', compact(p.echoes)))
191  out.push(row('reputation', compact(p.reputation)))
192  out.push(row('heat', String(p.heat)))
193  if (p.subscription_tier && p.subscription_tier !== 'NONE') {
194    const until = p.subscription_expires_at ? String(p.subscription_expires_at).slice(0, 10) : ''
195    out.push(row('clearance', p.subscription_tier + (until ? ' — renews ' + until : '')))
196  }
197  if (botnet) {
198    out.push(row('botnet', botnet.total_nodes + ' nodes · health ' + Math.round(botnet.aggregate_health || 0) + '%'
199      + (botnet.critical_count ? ' · ' + botnet.critical_count + ' critical' : '')
200      + (botnet.offline_count ? ' · ' + botnet.offline_count + ' offline' : '')))
201  }
202  const unread = notif ? notif.unread_count : 0
203  out.push(row('notifications', unread + ' unread'))
204  out.push('')
205  out.push('  /hw notif — read transmissions · /hw read — clear the badge')
206  return out.join('\n')
207}
208
209function notifText(notif) {
210  const list = (notif && notif.notifications) || []
211  if (!list.length) return 'No transmissions. The wire is quiet.'
212  const out = list.map((n) => {
213    const mark = n.read_at ? ' ' : '*'
214    return ' ' + mark + ' ' + severityMark(n.severity) + ' ' + timeAgo(n.created_at).padStart(7) + '  ' + (n.title || n.type || 'transmission')
215      + (n.body && !n.read_at ? '\n       ' + String(n.body).split('\n')[0] : '')
216  })
217  const unread = notif.unread_count || 0
218  out.push('')
219  out.push('  ' + unread + ' unread · * = new · /hw read clears the badge')
220  return out.join('\n')
221}
222
223// ---- login ------------------------------------------------------------------
224
225// Browser pairing (RFC 8628 device flow): no password ever touches the
226// terminal. The player opens the pairing URL, authorizes the code from their
227// logged-in game session, and this poller picks the tokens up.
228async function startDeviceLogin($) {
229  const tokens = await $.store.get('hw.tokens')
230  if (tokens) return 'Already logged in. Run /hw logout first to switch operator.'
231  stopDeviceFlow()
232
233  let start
234  try {
235    start = await client($).deviceStart()
236  } catch (err) {
237    return '◤ Pairing failed to start — ' + ((err && err.message) || 'unknown error') + '. Fallback: /hw login pass'
238  }
239  deviceFlow = {
240    timer: null,
241    deviceCode: start.deviceCode,
242    expiresAt: start.expiresAt,
243    intervalMs: Math.max(2000, start.intervalMs || 5000),
244  }
245  armDeviceTimer($)
246  tickDeviceFlow($)
247
248  const mins = Math.max(1, Math.round((start.expiresAt - Date.now()) / 60000))
249  return [
250    '◤ HNDLR UPLINK — RIG PAIRING',
251    '',
252    '  1. open  ' + start.verificationUriComplete,
253    '     (or visit ' + start.verificationUri + ' and enter the code)',
254    '  2. authorize this terminal from your operator account',
255    '  3. the uplink connects itself once approved',
256    '',
257    '  code     ' + start.userCode,
258    '  expires  in ' + mins + ' min · /hw login cancel aborts',
259    '',
260    '  No password is typed here — your browser session vouches for you.',
261    '  Legacy password prompt: /hw login pass',
262  ].join('\n')
263}
264
265function armDeviceTimer($) {
266  if (!deviceFlow) return
267  if (deviceFlow.timer) {
268    if (typeof deviceFlow.timer === 'function') deviceFlow.timer()
269    else if (deviceFlow.timer.cancel) deviceFlow.timer.cancel()
270  }
271  deviceFlow.timer = $.clock.every(deviceFlow.intervalMs, () => { tickDeviceFlow($) })
272}
273
274function stopDeviceFlow() {
275  if (deviceFlow && deviceFlow.timer) {
276    if (typeof deviceFlow.timer === 'function') deviceFlow.timer()
277    else if (deviceFlow.timer.cancel) deviceFlow.timer.cancel()
278  }
279  deviceFlow = null
280}
281
282async function tickDeviceFlow($) {
283  if (!deviceFlow) return
284  if (Date.now() >= deviceFlow.expiresAt) {
285    stopDeviceFlow()
286    $.ui.status(undefined)
287    $.ui.toast('◤ HNDLR ◢ pairing expired — run /hw login')
288    return
289  }
290
291  let res
292  try {
293    res = await client($).devicePoll(deviceFlow.deviceCode)
294  } catch {
295    return // transient network failure — the next tick retries
296  }
297
298  if (res.status === 'pending') {
299    const left = Math.max(0, Math.round((deviceFlow.expiresAt - Date.now()) / 1000))
300    const clock = Math.floor(left / 60) + ':' + String(left % 60).padStart(2, '0')
301    $.ui.status('◤HW pairing — approve the code in your browser (' + clock + ' left)')
302    return
303  }
304  if (res.status === 'slowDown') {
305    deviceFlow.intervalMs = Math.min(15000, deviceFlow.intervalMs * 2)
306    armDeviceTimer($)
307    return
308  }
309  if (res.status === 'expired') {
310    stopDeviceFlow()
311    $.ui.status(undefined)
312    $.ui.toast('◤ HNDLR ◢ pairing expired — run /hw login')
313    return
314  }
315  // Linked: tokens are already persisted by devicePoll().
316  stopDeviceFlow()
317  $.ui.toast('◤ HNDLR ◢ rig linked — uplink established')
318  failStreak = 0
319  await startPolling($)
320  await launchPanel($)
321}
322
323async function startLogin($, emailArg) {
324  const tokens = await $.store.get('hw.tokens')
325  if (tokens) return 'Already logged in. Run /hw logout first to switch operator.'
326  stopDeviceFlow()
327
328  // Headless path: /hw login email with HIDDENWARS_PASSWORD set in the env —
329  // for players who don't want to type the password in a pane.
330  const envPassword = await $.env.get('HIDDENWARS_PASSWORD')
331  if (emailArg && envPassword) {
332    const c = client($)
333    const res = await c.login(emailArg, envPassword)
334    if (res.requires2fa) {
335      return '2FA is enabled on this account — rerun without HIDDENWARS_PASSWORD to enter a code: /hw login ' + emailArg
336    }
337    $.ui.toast('◤ HNDLR ◢ uplink established')
338    await startPolling($)
339    return 'Uplink established. Tip: unset HIDDENWARS_PASSWORD now.'
340  }
341
342  loginFlow = { step: 'email', email: String(emailArg || '').toLowerCase(), password: '', totpSession: '', error: '', prefill: String(emailArg || '').toLowerCase() }
343  const opened = await $.ui.open({ id: 'hw-login', title: 'HW UPLINK', focus: true, closeOnEscape: true })
344  $.ui.invalidate('ui.render')
345  return opened && opened.isPlaced
346    ? 'UPLINK pane open — enter your credentials there.'
347    : 'Login pane is waiting — widen the terminal (144+ columns) to see it.'
348}
349
350function submitEmail($, value) {
351  const v = String(value || '').trim().toLowerCase()
352  if (!v || !v.includes('@')) {
353    loginFlow.error = 'Enter the email your operator account is registered to.'
354  } else {
355    loginFlow.email = v
356    loginFlow.step = 'password'
357    loginFlow.error = ''
358  }
359  $.ui.invalidate('ui.render')
360}
361
362async function submitPassword($, value) {
363  if (!value) {
364    loginFlow.error = 'Password required.'
365    $.ui.invalidate('ui.render')
366    return
367  }
368  loginFlow.password = value
369  loginFlow.step = 'busy'
370  loginFlow.error = ''
371  $.ui.invalidate('ui.render')
372  try {
373    const res = await client($).login(loginFlow.email, loginFlow.password)
374    loginFlow.password = ''
375    if (res.requires2fa) {
376      loginFlow.totpSession = res.totpSession
377      loginFlow.step = 'totp'
378    } else {
379      await finishLogin($)
380      return
381    }
382  } catch (err) {
383    loginFlow.step = 'password'
384    loginFlow.error = (err && err.message) || 'Login failed.'
385  }
386  $.ui.invalidate('ui.render')
387}
388
389async function submitTotp($, value) {
390  const code = String(value || '').replace(/\s+/g, '')
391  loginFlow.step = 'busy'
392  loginFlow.error = ''
393  $.ui.invalidate('ui.render')
394  try {
395    await client($).totpLogin(loginFlow.totpSession, code)
396    await finishLogin($)
397  } catch (err) {
398    loginFlow.step = 'totp'
399    loginFlow.error = (err && err.message) || 'Verification failed.'
400    $.ui.invalidate('ui.render')
401  }
402}
403
404async function finishLogin($) {
405  loginFlow = null
406  await $.ui.close({ id: 'hw-login' })
407  $.ui.toast('◤ HNDLR ◢ uplink established')
408  failStreak = 0
409  await startPolling($)
410  await launchPanel($)
411}
412
413function cancelLogin($) {
414  loginFlow = null
415  $.ui.close({ id: 'hw-login' })
416}
417
418// ---- background uplink --------------------------------------------------------
419
420function client($) {
421  return createClient({
422    base: baseCache,
423    http: (url, init) => $.http.fetch(url, init),
424    getTokens: async () => (await $.store.get('hw.tokens')) || null,
425    setTokens: async (t) => {
426      if (t) await $.store.set('hw.tokens', t)
427      else await $.store.delete('hw.tokens')
428    },
429  })
430}
431
432function stopPolling() {
433  if (pollTimer) {
434    if (typeof pollTimer === 'function') pollTimer()
435    else if (pollTimer.cancel) pollTimer.cancel()
436    pollTimer = null
437  }
438}
439
440async function startPolling($) {
441  const tokens = await $.store.get('hw.tokens')
442  const sec = pollSeconds(await $.store.get('hw.pollSec'))
443  stopPolling()
444  if (!tokens || !tokens.refresh_token || sec === 0) return false
445  failStreak = 0
446  pollTimer = $.clock.every(sec * 1000, () => { pollOnce($) })
447  pollOnce($)
448  return true
449}
450
451function pollSeconds(stored) {
452  const n = Number(stored)
453  if (!Number.isFinite(n)) return DEFAULT_POLL_SEC
454  if (n === 0) return 0 // explicit off
455  return Math.min(MAX_POLL_SEC, Math.max(MIN_POLL_SEC, Math.round(n)))
456}
457
458async function pollOnce($) {
459  try {
460    const c = client($)
461    const [me, notif] = await Promise.all([
462      c.get('/player'),
463      c.get('/player/notifications?limit=25'),
464    ])
465    failStreak = 0
466    const p = (me && me.player) || {}
467    const unread = notif ? notif.unread_count : 0
468    $.ui.status('◤HW ' + (p.username || 'operator') + ' · crypto ' + compact(p.crypto)
469      + ' · heat ' + p.heat + ' · ' + unread + ' unread')
470    await announceNew($, (notif && notif.notifications) || [])
471  } catch (err) {
472    if (err && err.code === 'SESSION_EXPIRED') {
473      stopPolling()
474      $.ui.status('◤HW uplink expired — run /hw login')
475      return
476    }
477    failStreak += 1
478    if (failStreak >= 5) {
479      stopPolling()
480      $.ui.status('◤HW uplink lost — run /hw to reconnect')
481    }
482  }
483}
484
485// Toasts alert-grade transmissions once; every id is remembered so nothing
486// repeats across polls or sessions. First poll after login seeds silently.
487async function announceNew($, list) {
488  const seen = new Set((await $.store.get('hw.seen')) || [])
489  const fresh = list.filter((n) => !seen.has(n.id))
490  if (seen.size === 0) {
491    for (const n of list) seen.add(n.id)
492  } else {
493    for (const n of fresh) {
494      seen.add(n.id)
495      const sev = String(n.severity || '').toLowerCase()
496      if (!n.read_at && (sev === 'danger' || sev === 'warning')) {
497        $.ui.toast('◤ HNDLR ◢ ' + (n.title || n.type || 'transmission'))
498      }
499    }
500  }
501  await $.store.set('hw.seen', Array.from(seen).slice(-SEEN_CAP))
502}
503
504// ---- dashboard panel ----------------------------------------------------------
505
506// Open (or re-arm) the dashboard pane: wire the button handlers, start the
507// refresh timer, draw once. Returns whether the pane placed. Shared by
508// /hw panel and the post-login auto-launch.
509async function launchPanel($) {
510  if (!panelState) {
511    panelState = { timer: null, data: {} }
512  }
513  // Button handlers: closures over $ so the pure builders in panel.js stay
514  // free of the mods API.
515  panelState.data.onRefresh = () => { refreshPanel($) }
516  panelState.data.onReadAll = async () => {
517    try {
518      await client($).markAllRead()
519      await refreshPanel($)
520    } catch (err) {
521      if (panelState) {
522        panelState.data.error = (err && err.message) || 'read-all failed'
523        $.ui.invalidate('ui.render')
524      }
525    }
526  }
527  panelState.data.onClose = () => { $.ui.close({ id: 'hw-panel' }) }
528
529  const opened = await $.ui.open({ id: 'hw-panel', title: 'HW UPLINK', focus: true, closeOnEscape: true, columns: 44 })
530  stopPanelTimer()
531  const sec = pollSeconds(await $.store.get('hw.pollSec')) || DEFAULT_POLL_SEC
532  panelState.timer = $.clock.every(sec * 1000, () => { refreshPanel($) })
533  refreshPanel($)
534  return !!(opened && opened.isPlaced)
535}
536
537async function openPanelCommand($) {
538  const placed = await launchPanel($)
539  return placed
540    ? 'UPLINK panel open — Tab cycles controls, x closes.'
541    : 'UPLINK panel waiting — widen the terminal (144+ columns) to see it.'
542}
543
544function stopPanelTimer() {
545  if (panelState && panelState.timer) {
546    if (typeof panelState.timer === 'function') panelState.timer()
547    else if (panelState.timer.cancel) panelState.timer.cancel()
548    panelState.timer = null
549  }
550}
551
552// One pass over the four read endpoints; each failure degrades its own
553// section and keeps the last good data. A dead session stops the panel
554// timer the same way the background poller stops itself.
555async function refreshPanel($) {
556  if (!panelState) return
557  const c = client($)
558  let sessionDead = false
559  const soft = (err) => {
560    if (err && err.code === 'SESSION_EXPIRED') sessionDead = true
561    return null
562  }
563  const [state, notif, wire, botnet] = await Promise.all([
564    c.get('/player/state').catch(soft),
565    c.get('/player/notifications?limit=6').catch(soft),
566    c.get('/wire/latest').catch(soft),
567    c.get('/botnet/summary').catch(soft),
568  ])
569  if (sessionDead) {
570    stopPanelTimer()
571    stopPolling()
572    panelState.data.error = 'Session expired — /hw login'
573    panelState.data.updatedAt = Date.now()
574    $.ui.status('◤HW uplink expired — run /hw login')
575    $.ui.invalidate('ui.render')
576    return
577  }
578  if (state) panelState.data.state = state
579  if (notif) panelState.data.notif = notif
580  if (wire) panelState.data.wire = wire
581  if (botnet) panelState.data.botnet = botnet
582  panelState.data.error = state || notif ? '' : 'uplink error — retrying next refresh'
583  panelState.data.updatedAt = Date.now()
584  $.ui.invalidate('ui.render')
585}
586
587// ---- config -------------------------------------------------------------------
588
589async function setPoll($, arg) {
590  if (arg === undefined) {
591    const sec = pollSeconds(await $.store.get('hw.pollSec'))
592    return 'Poll cadence: ' + (sec === 0 ? 'off' : sec + 's') + ' (min ' + MIN_POLL_SEC + 's, max ' + MAX_POLL_SEC + 's).'
593  }
594  if (arg.toLowerCase() === 'off') {
595    await $.store.set('hw.pollSec', 0)
596    stopPolling()
597    $.ui.status(undefined)
598    return 'Background polling off.'
599  }
600  const wanted = parseInt(arg, 10)
601  if (!Number.isFinite(wanted) || wanted < MIN_POLL_SEC) {
602    return 'Give seconds between ' + MIN_POLL_SEC + ' and ' + MAX_POLL_SEC + ', or "off".'
603  }
604  const sec = pollSeconds(wanted)
605  await $.store.set('hw.pollSec', sec)
606  await startPolling($)
607  return 'Polling every ' + sec + 's.'
608}
609
610async function setApi($, arg) {
611  if (arg === undefined) return 'API base: ' + baseCache + (baseCache === DEFAULT_BASE ? ' (default)' : '')
612  if (arg.toLowerCase() === 'reset') {
613    await $.store.delete('hw.base')
614    baseCache = DEFAULT_BASE
615    return 'API base reset to ' + DEFAULT_BASE
616  }
617  if (!/^https?:\/\//.test(arg)) return 'Give an absolute http(s) URL, or "reset".'
618  baseCache = arg.replace(/\/+$/, '')
619  await $.store.set('hw.base', baseCache)
620  return 'API base set to ' + baseCache
621}
622
hooks/api.js 177 lines
1// HiddenWars API client for the HNDLR uplink mod.
2//
3// This module is deliberately free of the mods API (`$`): the host's
4// `$.http.fetch`, token storage and base-URL config are injected by
5// register.js, so everything here is plain, testable JavaScript.
6//
7// Auth model (matches the game backend):
8//   POST /auth/login          {email, password} -> {token, refresh_token}
9//                             or {requires_2fa, totp_session}
10//   POST /auth/totp/login     {totp_session, code} -> {token, refresh_token}
11//   POST /auth/refresh        {refresh_token} -> {token, refresh_token}
12//                             (refresh token rotates on every call)
13//   POST /auth/device/start   -> pairing URL + codes (browser login flow)
14//   POST /auth/device/poll    {device_code} -> pending | tokens
15// Access tokens live 1h; refresh tokens 30d. The client retries once on a
16// 401 by refreshing first.
17
18export const DEFAULT_BASE = 'https://api.hiddenwars.io'
19
20export class ApiError extends Error {
21  constructor(message, status, code) {
22    super(message)
23    this.name = 'ApiError'
24    this.status = status
25    this.code = code || ''
26  }
27}
28
29export function createClient({ base, http, getTokens, setTokens }) {
30  const root = String(base || DEFAULT_BASE).replace(/\/+$/, '')
31
32  async function request(path, { method, body, auth = true, retry = true } = {}) {
33    const headers = {
34      'Content-Type': 'application/json',
35      'User-Agent': 'hiddenwars-uplink/0.4 (claude-code-mod)',
36      'X-Client-Source': 'claude-code-mod',
37    }
38    if (auth) {
39      const tokens = await getTokens()
40      if (tokens && tokens.token) headers.Authorization = 'Bearer ' + tokens.token
41    }
42    const res = await http(root + path, {
43      method: method || 'GET',
44      headers,
45      body: body === undefined ? undefined : JSON.stringify(body),
46    })
47
48    if (res.status === 401 && auth && retry) {
49      const refreshed = await refresh()
50      if (refreshed) return request(path, { method, body, auth, retry: false })
51    }
52
53    let json = null
54    try {
55      json = res.text ? JSON.parse(res.text) : null
56    } catch {
57      json = null
58    }
59    if (!res.ok) {
60      const message = (json && json.message) || 'request failed (' + res.status + ')'
61      const code = (json && json.error) || ''
62      throw new ApiError(message, res.status, code)
63    }
64    return json
65  }
66
67  async function refresh() {
68    const tokens = await getTokens()
69    if (!tokens || !tokens.refresh_token) return false
70    const res = await http(root + '/auth/refresh', {
71      method: 'POST',
72      headers: { 'Content-Type': 'application/json' },
73      body: JSON.stringify({ refresh_token: tokens.refresh_token }),
74    })
75    if (!res.ok) {
76      // The stored refresh token is dead or rotated away; the session is over.
77      await setTokens(null)
78      throw new ApiError('Session expired — run /hw login', 401, 'SESSION_EXPIRED')
79    }
80    const json = JSON.parse(res.text)
81    await setTokens({ token: json.token, refresh_token: json.refresh_token })
82    return true
83  }
84
85  return {
86    // login() resolves {ok:true} on success or {requires2fa:true, totpSession}
87    // when the account has 2FA. Throws ApiError with the server's message
88    // (invalid credentials, email not verified, ...) otherwise.
89    async login(email, password) {
90      const json = await request('/auth/login', {
91        method: 'POST',
92        body: { email: String(email).toLowerCase(), password },
93        auth: false,
94      })
95      if (json && json.requires_2fa) {
96        return { requires2fa: true, totpSession: json.totp_session }
97      }
98      await setTokens({ token: json.token, refresh_token: json.refresh_token })
99      return { ok: true }
100    },
101
102    async totpLogin(totpSession, code) {
103      const json = await request('/auth/totp/login', {
104        method: 'POST',
105        body: { totp_session: totpSession, code },
106        auth: false,
107      })
108      await setTokens({ token: json.token, refresh_token: json.refresh_token })
109      return { ok: true }
110    },
111
112    async logout() {
113      const tokens = await getTokens()
114      try {
115        await request('/auth/logout', {
116          method: 'POST',
117          body: tokens && tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {},
118        })
119      } catch {
120        // Best-effort: the server may already be unreachable; clear locally
121        // regardless.
122      }
123      await setTokens(null)
124    },
125
126    // Device pairing (RFC 8628 subset): the browser-login path behind
127    // /hw login. start() mints a grant and returns everything the terminal
128    // needs to print the pairing URL; poll() is called on a clock until the
129    // player approves the code at the verification page.
130    async deviceStart() {
131      const json = await request('/auth/device/start', {
132        method: 'POST',
133        body: { client_label: 'claude-code-mod' },
134        auth: false,
135      })
136      return {
137        deviceCode: json.device_code,
138        userCode: json.user_code,
139        verificationUri: json.verification_uri,
140        verificationUriComplete: json.verification_uri_complete,
141        expiresAt: Date.now() + (json.expires_in || 600) * 1000,
142        intervalMs: (json.interval || 5) * 1000,
143      }
144    },
145
146    // poll() never throws for flow states: pending / slowDown / expired are
147    // returned as {status}. Only real failures (network, server 5xx) throw,
148    // which the caller treats as transient and retries on the next tick.
149    async devicePoll(deviceCode) {
150      try {
151        const json = await request('/auth/device/poll', {
152          method: 'POST',
153          body: { device_code: deviceCode },
154          auth: false,
155        })
156        await setTokens({ token: json.token, refresh_token: json.refresh_token })
157        return { status: 'ok' }
158      } catch (err) {
159        if (err instanceof ApiError) {
160          if (err.code === 'authorization_pending') return { status: 'pending' }
161          if (err.code === 'slow_down') return { status: 'slowDown' }
162          if (err.code === 'expired_token') return { status: 'expired' }
163        }
164        throw err
165      }
166    },
167
168    get(path) {
169      return request(path)
170    },
171
172    markAllRead() {
173      return request('/player/notifications/read', { method: 'PUT' })
174    },
175  }
176}
177
hooks/panel.js 312 lines
1// Pure builders for the HW UPLINK dashboard pane — no mods API, no network.
2// register.js resolves the element constructors (Box/Text/Button) from
3// $.ui.resolve(e) and passes them in, so everything here is unit-testable
4// under plain node.
5//
6// Data comes from GET /player/state, /player/notifications, /botnet/summary
7// and /wire/latest (see api.js / register.js refreshPanel).
8//
9// Layout uses the Ink-style props the pane surface exposes (borderStyle,
10// padding, flexGrow, justifyContent…): a solid inverse title bar, one round
11// bordered card carrying every number (stat grid, vault, heat gauge, botnet
12// rollup), then chip-headed list sections, then a one-line footer.
13
14import { compact, timeAgo } from './format.js'
15
16// models.HeatLockoutThreshold in the backend — the heat bar's full scale.
17export const HEAT_MAX = 200
18
19const SEV_COLORS = { danger: 'red', warning: 'yellow', success: 'green', info: 'gray' }
20const STAT_COLORS = { crypto: 'cyan', fragments: 'magenta', echoes: 'blue', reputation: 'green' }
21
22export function heatRatio(heat, threshold) {
23  const h = Number(heat) || 0
24  const t = Number(threshold) || HEAT_MAX
25  return Math.max(0, Math.min(1, h / t))
26}
27
28export function heatBar(heat, threshold, cells = 14) {
29  const filled = Math.round(heatRatio(heat, threshold) * cells)
30  return '█'.repeat(filled) + '░'.repeat(cells - filled)
31}
32
33export function heatColor(heat, threshold) {
34  const r = heatRatio(heat, threshold)
35  if (r >= 1) return 'red'
36  if (r >= 0.5) return 'yellow'
37  return 'green'
38}
39
40// buildPanel(el, data, width) -> element tree for the hw-panel pane.
41//   el   { Box, Text, Button } as resolved by $.ui.resolve(e)
42//   data { state, notif, botnet, wire, error, updatedAt } — any may be absent
43//   width  the pane's body width in cells (for the title bar / headers)
44export function buildPanel(el, data, width = 44) {
45  const { Box, Text, Button } = el
46  const state = data.state || {}
47  const p = state.player || {}
48  const lock = state.heat_lockout || {}
49  const vault = state.vault || {}
50  const notif = data.notif || {}
51  const botnet = data.botnet
52  const wireItems = (data.wire && data.wire.items) || []
53  const inner = Math.max(20, Math.min(48, (width || 44) - 2))
54
55  const children = []
56
57  // ── title bar: inverse strip, status right-aligned ──────────────────────
58  const online = !!(p.username || data.updatedAt)
59  const right = data.error
60    ? 'NO LINK'
61    : online ? 'LIVE ' + clock(data.updatedAt || Date.now()) : 'STANDBY'
62  const title = '◤ HNDLR UPLINK'
63  const gap = Math.max(1, inner - title.length - right.length)
64  children.push(Text({
65    inverse: true,
66    bold: true,
67    color: data.error ? 'red' : undefined,
68    children: [title + ' '.repeat(gap) + right],
69  }))
70
71  // ── identity ────────────────────────────────────────────────────────────
72  if (p.username) {
73    const tier = String(p.subscription_tier || '').toUpperCase()
74    const meta = [p.archetype, tier && tier !== 'NONE' && tier !== 'FREE' ? tier : '']
75      .filter(Boolean).join(' · ')
76    children.push(Box({
77      flexDirection: 'row',
78      columnGap: 1,
79      children: [
80        Text({ bold: true, children: [p.username] }),
81        meta ? Text({ dimColor: true, children: [meta] }) : null,
82      ].filter(Boolean),
83    }))
84  }
85
86  // ── the numbers card ────────────────────────────────────────────────────
87  if (p.username) {
88    const card = []
89
90    const stats = [
91      ['crypto', compact(p.crypto) + (p.dirty_crypto ? ' +' + compact(p.dirty_crypto) : '')],
92      ['fragments', compact(p.fragments)],
93    ]
94    if (p.echoes) stats.push(['echoes', compact(p.echoes)])
95    stats.push(['reputation', compact(p.reputation)])
96    for (let i = 0; i < stats.length; i += 2) {
97      card.push(Box({
98        flexDirection: 'row',
99        columnGap: 1,
100        marginTop: 1,
101        children: stats.slice(i, i + 2).map(([label, value]) => statCell(el, label, value)),
102      }))
103    }
104
105    if (Number(vault.cap) > 0) {
106      const bits = ['protected ' + compact(vault.protected) + '/' + compact(vault.cap)]
107      if (Number(vault.exposed) > 0) bits.push(compact(vault.exposed) + ' exposed')
108      if (vault.mining_halted) bits.push('MINING HALTED')
109      const warn = vault.mining_halted || Number(vault.exposed) > 0
110      card.push(Box({
111        flexDirection: 'row',
112        columnGap: 1,
113        marginTop: 1,
114        children: [
115          Text({ dimColor: true, children: ['VAULT'] }),
116          Text({ color: warn ? 'yellow' : undefined, children: [bits.join(' · ')] }),
117        ],
118      }))
119    }
120
121    const heat = Number(p.heat) || 0
122    card.push(Box({
123      flexDirection: 'row',
124      columnGap: 1,
125      marginTop: 1,
126      children: [
127        Text({ dimColor: true, children: ['HEAT'] }),
128        Text({ color: heatColor(heat, lock.threshold), children: [heatBar(heat, lock.threshold)] }),
129        Text({ dimColor: true, children: [heat + '/' + (lock.threshold || HEAT_MAX)] }),
130      ].concat(lock.locked ? [Text({ inverse: true, bold: true, color: 'red', children: [' LOCKED '] })] : []),
131    }))
132
133    if (botnet) {
134      card.push(Box({
135        flexDirection: 'row',
136        columnGap: 1,
137        marginTop: 1,
138        children: [
139          Text({ dimColor: true, children: ['BOTNET'] }),
140          Text({ children: [botnet.total_nodes + ' nodes · ' + Math.round(botnet.aggregate_health || 0) + '%'] }),
141        ],
142      }))
143      const trouble = []
144      if (botnet.critical_count) trouble.push(botnet.critical_count + ' critical')
145      if (botnet.offline_count) trouble.push(botnet.offline_count + ' offline')
146      if (trouble.length) {
147        card.push(Text({ color: 'red', children: ['! ' + trouble.join(' · ')] }))
148      }
149    }
150
151    children.push(Box({
152      borderStyle: 'round',
153      borderDimColor: true,
154      flexDirection: 'column',
155      marginTop: 1,
156      paddingX: 1,
157      children: card,
158    }))
159  } else if (!data.error) {
160    children.push(Box({
161      borderStyle: 'round',
162      borderDimColor: true,
163      marginTop: 1,
164      paddingX: 1,
165      children: [Text({ dimColor: true, children: ['establishing uplink…'] })],
166    }))
167  }
168
169  // ── active operations ───────────────────────────────────────────────────
170  const ops = activeOps(state)
171  if (ops.length) {
172    children.push(sectionHeader(el, 'OPERATIONS', inner))
173    for (const op of ops) {
174      children.push(Text({ color: 'magenta', children: ['▲ ' + op] }))
175    }
176  }
177
178  // ── transmissions ───────────────────────────────────────────────────────
179  if (p.username) {
180    const unread = notif.unread_count || 0
181    children.push(sectionHeader(el, 'TRANSMISSIONS', inner, unread ? { text: unread + ' unread', color: 'yellow' } : null))
182    const list = notif.notifications || []
183    if (!list.length) {
184      children.push(Text({ dimColor: true, children: ['the wire is quiet'] }))
185    } else {
186      for (const n of list.slice(0, 6)) {
187        children.push(Box({
188          flexDirection: 'row',
189          justifyContent: 'space-between',
190          columnGap: 1,
191          children: [
192            Text({
193              bold: !n.read_at,
194              dimColor: !!n.read_at,
195              color: SEV_COLORS[String(n.severity || '').toLowerCase()] || 'gray',
196              wrap: 'truncate-end',
197              children: [(n.read_at ? '  ' : '› ') + (n.title || n.type || 'transmission')],
198            }),
199            Text({ dimColor: true, children: [timeAgo(n.created_at)] }),
200          ],
201        }))
202      }
203    }
204  }
205
206  // ── the wire ────────────────────────────────────────────────────────────
207  if (wireItems.length) {
208    children.push(sectionHeader(el, 'THE WIRE', inner))
209    for (const item of wireItems.slice(0, 4)) {
210      children.push(Box({
211        flexDirection: 'row',
212        justifyContent: 'space-between',
213        columnGap: 1,
214        children: [
215          Text({
216            wrap: 'truncate-end',
217            color: item.pinned ? 'cyan' : undefined,
218            children: ['► ' + humanizeMentions(item.headline || item.body || '', item.mention_names)],
219          }),
220          Text({ dimColor: true, children: [timeAgo(item.occurredAt)] }),
221        ],
222      }))
223    }
224  }
225
226  // ── footer: freshness/error left, controls right ────────────────────────
227  children.push(Box({
228    flexDirection: 'row',
229    justifyContent: 'space-between',
230    marginTop: 1,
231    children: [
232      data.error
233        ? Text({ color: 'red', children: [data.error] })
234        : Text({ dimColor: true, children: [data.updatedAt ? 'updated ' + clock(data.updatedAt) : ''] }),
235      Box({
236        flexDirection: 'row',
237        columnGap: 2,
238        children: [
239          Button({ key: 'hw-panel-refresh', label: 'refresh', hotkey: 'r', plain: true, dimColor: true, onPress: data.onRefresh || noop }),
240          Button({ key: 'hw-panel-read', label: 'read all', hotkey: 'm', plain: true, dimColor: true, onPress: data.onReadAll || noop }),
241          Button({ key: 'hw-panel-close', label: 'close', hotkey: 'x', plain: true, dimColor: true, onPress: data.onClose || noop }),
242        ],
243      }),
244    ],
245  }))
246
247  return Box({ flexDirection: 'column', children })
248}
249
250// Everything active right now that an operator should glance at. Shapes are
251// defensive: /player/state may add fields; presence is what matters here.
252// Wire copy embeds "@[uuid]" mention tokens (the game frontend renders them
253// as clickable profiles); mention_names on each wire item resolves them so
254// this plain-text panel shows handles. Unknown ids read @unknown.
255export function humanizeMentions(text, names) {
256  return String(text || '').replace(/@\[([0-9a-fA-F-]{36})\]/g, (token, id) => {
257    const name = names && names[id]
258    return name ? '@' + name : '@unknown'
259  })
260}
261
262// Everything active right now that an operator should glance at. Shapes are
263// defensive: /player/state may add fields; presence is what matters here.
264export function activeOps(state) {
265  const ops = []
266  const boss = state.active_world_boss
267  if (boss) ops.push('WORLD BOSS — ' + (boss.name || boss.title || 'ACTIVE'))
268  if (state.siege_season) ops.push('SIEGE SEASON — ' + (state.siege_season.name || state.siege_season.season_name || 'DESCENT OPEN'))
269  const event = state.active_event
270  if (event) ops.push('EVENT — ' + (event.name || event.title || 'LIVE'))
271  return ops
272}
273
274// A label-over-value cell; flexGrow makes pairs split the card's width.
275function statCell(el, label, value) {
276  const { Box, Text } = el
277  return Box({
278    flexGrow: 1,
279    flexDirection: 'column',
280    children: [
281      Text({ dimColor: true, children: [label.toUpperCase()] }),
282      Text({ bold: true, color: STAT_COLORS[label], children: [value] }),
283    ],
284  })
285}
286
287// ─ TRANSMISSIONS · 3 unread ─────────── with an optional highlighted badge.
288function sectionHeader(el, label, width, badge = null) {
289  const { Box, Text } = el
290  const lead = '─ ' + label
291  const badgeText = badge ? ' · ' + badge.text : ''
292  const tailLen = Math.max(1, width - (lead.length + badgeText.length + 1))
293  return Box({
294    flexDirection: 'row',
295    marginTop: 1,
296    children: [
297      Text({ dimColor: true, bold: true, children: [lead] }),
298      badge ? Text({ bold: true, color: badge.color || 'yellow', children: [badgeText] }) : null,
299      Text({ dimColor: true, children: [' ' + '─'.repeat(tailLen)] }),
300    ].filter(Boolean),
301  })
302}
303
304function clock(ts) {
305  const d = new Date(ts)
306  const hh = String(d.getHours()).padStart(2, '0')
307  const mm = String(d.getMinutes()).padStart(2, '0')
308  return hh + ':' + mm
309}
310
311function noop() {}
312
hooks/format.js 47 lines
1// Pure output helpers for the HNDLR uplink mod — no mods API, no network.
2
3// 1234567 -> "1.2M", 999 -> "999", 1250 -> "1.3k", undefined -> "0".
4export function compact(n) {
5  const v = Number(n)
6  if (!Number.isFinite(v)) return '0'
7  const abs = Math.abs(v)
8  const sign = v < 0 ? '-' : ''
9  if (abs >= 1e9) return sign + trim(abs / 1e9) + 'B'
10  if (abs >= 1e6) return sign + trim(abs / 1e6) + 'M'
11  if (abs >= 1e3) return sign + trim(abs / 1e3) + 'k'
12  return sign + String(Math.round(abs))
13}
14
15function trim(x) {
16  const s = x >= 100 ? String(Math.round(x)) : x.toFixed(1)
17  return s.replace(/\.0$/, '')
18}
19
20// ISO timestamp -> "3m ago" / "5h ago" / "2d ago"; falls back to the raw
21// string when the date can't be parsed.
22export function timeAgo(iso, now = Date.now()) {
23  const t = Date.parse(iso)
24  if (!Number.isFinite(t)) return String(iso || '')
25  const sec = Math.max(0, Math.floor((now - t) / 1000))
26  if (sec < 60) return sec + 's ago'
27  if (sec < 3600) return Math.floor(sec / 60) + 'm ago'
28  if (sec < 86400) return Math.floor(sec / 3600) + 'h ago'
29  return Math.floor(sec / 86400) + 'd ago'
30}
31
32// Severity -> a short terminal marker. Game severities: info, success,
33// warning, danger.
34export function severityMark(sev) {
35  switch (String(sev || '').toLowerCase()) {
36    case 'danger': return '[!!]'
37    case 'warning': return '[! ]'
38    case 'success': return '[ +]'
39    default: return '[ i]'
40  }
41}
42
43// Right-pad a label so value columns line up in command output.
44export function row(label, value, width = 14) {
45  return '  ' + String(label).padEnd(width) + String(value)
46}
47