HNDLR uplink — monitor your HiddenWars operator stats and notifications from 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.
/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.◤HW <operator> · crypto 1.2M · heat 22 · 3 unread line lives under your prompt and refreshes itself.◤ HNDLR ◢ toasts the moment they land./hw login / /hw logout / /hw poll <sec|off> / /hw api <url> — session and background polling controls.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.
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.
https://api.hiddenwars.io by default; check any time with /hw api).~/.claude/plugins/store/. /hw logout revokes it server-side and erases it locally./hw read. It polls at most once per 30s (/hw poll to change, /hw poll off to stop).hiddenwars-uplink/0.4 (claude-code-mod).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. ◤
hooks/register.js 622 lines1// 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}
622hooks/api.js 177 lines1// 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}
177hooks/panel.js 312 lines1// 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() {}
312hooks/format.js 47 lines1// 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