SLOPSHOPPER

xyra-alert

Toasts from Xyra while you work with Claude: a League of Legends match found, accepted, back in the queue, and the game starting with your champion.

newtoastnetworktimer
A shopper browsing a rack in a slop shop
README

xyra-alert

Toasts from Xyra while you work with Claude, so a League of Legends match never waits on you:

In League of LegendsToast
A match is foundMatch found!
It is accepted and champion select startsXyra accepted it. Champion select. (or Match accepted, when Xyra does not accept on its own)
Someone does not acceptSomeone did not accept. You are still in the queue. (or The match did not start, once out of the queue)
The game startsThe game started: your champion.

What the game already shows when a session starts is not announced. The toasts follow Claude Code's language (English or Spanish).

Install

At the prompt of a terminal session:

/plugin install xyra-alert --marketplace DiegoFernandoLojanTenesaca/indaga-mods

From a shell:

claude plugin install xyra-alert --marketplace DiegoFernandoLojanTenesaca/indaga-mods

How it reaches Xyra

It needs Xyra on the same computer with its phone link on. At the first session it pairs on its own, with the pairing code Xyra keeps in %APPDATA%\com.indagalab.xyra\config.json, and shows up among Xyra's phones as Claude Code. Then it listens to Xyra the way the phone app does (/api/state). While Xyra is closed it tries again every 30 seconds and says nothing.

Disconnecting Claude Code from Xyra's phones stops it for that session; it pairs again at the next one, so turn the mod off to stop it for good.

Options

OptionWhat it is
addressWhere Xyra's phone link answers. http://127.0.0.1:47811 by default.
Source 3 files
hooks/register.ts 106 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { alertsOf } from './alerts'
4import type { XyraState } from './alerts'
5import { localeOf, translate } from './locales'
6import type { Locale } from './locales'
7
8const DEFAULT_ADDRESS = 'http://127.0.0.1:47811'
9const DEVICE_NAME = 'Claude Code'
10const XYRA_CONFIG = ['com.indagalab.xyra', 'config.json']
11const TOKEN_KEY = 'token'
12const NEXT_MS = 1
13const RETRY_MS = 30_000
14const FORBIDDEN = 403
15
16type Config = { address: string }
17type Timer = { cancel: () => void }
18type StateAnswer = { version: number; state: XyraState }
19
20const configOf =(options: Record<string, unknown>): Config => ({
21  address: (typeof options.address === 'string' && options.address.trim() ? options.address.trim() : DEFAULT_ADDRESS).replace(/\/+$/, ''),
22})
23
24const joinPath = (folder: string, names: readonly string[]) => [folder.replace(/[\\/]+$/, ''), ...names].join(folder.includes('\\') ? '\\' : '/')
25
26const linkOf = (config: Config, path: string, token: string, extra = '') =>
27  `${config.address}${path}?t=${encodeURIComponent(token)}${extra}`
28
29async function sessionLocale($: EngineInterface): Promise<Locale> {
30  const language = await $.settings.read({ key: 'language' }).catch(() => undefined)
31  return localeOf(typeof language === 'string' ? language : undefined)
32}
33
34async function pairingCode($: EngineInterface) {
35  const folder = await $.env.get('APPDATA')
36  if (!folder) return undefined
37  const saved = JSON.parse(await $.fs.read(joinPath(folder, XYRA_CONFIG))) as { phone_token?: unknown }
38  return typeof saved.phone_token === 'string' && saved.phone_token ? saved.phone_token : undefined
39}
40
41async function tokenOf($: EngineInterface, config: Config) {
42  const kept = await $.store.get(TOKEN_KEY)
43  if (typeof kept === 'string' && kept) return kept
44  const code = await pairingCode($)
45  if (!code) return undefined
46  const paired = await $.http.fetch(linkOf(config, '/api/pair', code, `&name=${encodeURIComponent(DEVICE_NAME)}`), { method: 'POST' })
47  const token = paired.ok ? (JSON.parse(paired.text) as { token?: unknown }).token : undefined
48  if (typeof token !== 'string' || !token) return undefined
49  await $.store.set(TOKEN_KEY, token)
50  return token
51}
52
53async function acceptsAlone($: EngineInterface, config: Config, token: string) {
54  const settings = await $.http.fetch(linkOf(config, '/api/settings', token))
55  return settings.ok && (JSON.parse(settings.text) as { auto_accept?: unknown }).auto_accept === true
56}
57
58function watch($: EngineInterface, config: Config): Timer {
59  let timer: Timer | undefined
60  let seen: XyraState | undefined
61  let version: number | undefined
62  let locale: Locale | undefined
63
64  const schedule = (ms: number) => {
65    timer = $.clock.after(ms, () => void step().catch(() => schedule(RETRY_MS)))
66  }
67
68  const step = async () => {
69    const token = await tokenOf($, config)
70    if (!token) return schedule(RETRY_MS)
71    const answer = await $.http.fetch(linkOf(config, '/api/state', token, version === undefined ? '' : `&after=${version}`))
72    if (answer.status === FORBIDDEN) return $.store.delete(TOKEN_KEY)
73    if (!answer.ok) return schedule(RETRY_MS)
74    const latest = JSON.parse(answer.text) as StateAnswer
75    locale ??= await sessionLocale($)
76    for (const alert of seen ? alertsOf(seen, latest.state) : []) {
77      const key = alert.key === 'accepted' && (await acceptsAlone($, config, token)) ? 'acceptedByXyra' : alert.key
78      $.ui.toast(translate(locale, key, alert.values))
79    }
80    seen = latest.state
81    version = latest.version
82    schedule(NEXT_MS)
83  }
84
85  schedule(NEXT_MS)
86  return { cancel: () => timer?.cancel() }
87}
88
89export const register: Register = (on, options) => {
90  const config = configOf(options)
91  let watcher: Timer | undefined
92
93  on('session.start', async ($, e, next) => {
94    const started = await next(e)
95    watcher?.cancel()
96    watcher = watch($, config)
97    return started
98  })
99
100  on('session.end', async ($, e, next) => {
101    watcher?.cancel()
102    watcher = undefined
103    return next(e)
104  })
105}
106
hooks/alerts.ts 30 lines
1import type { MessageKey } from './locales'
2
3export type XyraPhase = 'noClient' | 'client' | 'champSelect' | 'inGame' | 'paused'
4
5type Champion = { name: string } | null
6
7export type XyraState = {
8  phase: XyraPhase
9  ready_check: boolean
10  champ_select: { champion: Champion } | null
11  game: { champion: Champion } | null
12  lobby: { searching: boolean } | null
13}
14
15export type XyraAlert = { key: MessageKey; values?: Record<string, string> }
16
17const PLAYING = new Set<XyraPhase>(['inGame', 'paused'])
18
19export function alertsOf(before: XyraState, after: XyraState): XyraAlert[] {
20  const alerts: XyraAlert[] = []
21  if (!before.ready_check && after.ready_check) alerts.push({ key: 'found' })
22  if (before.phase !== 'champSelect' && after.phase === 'champSelect') alerts.push({ key: before.ready_check ? 'accepted' : 'champSelect' })
23  if (before.ready_check && !after.ready_check && after.phase !== 'champSelect') alerts.push({ key: after.lobby?.searching ? 'requeued' : 'notFormed' })
24  if (!PLAYING.has(before.phase) && after.phase === 'inGame') {
25    const champion = after.game?.champion?.name ?? before.champ_select?.champion?.name
26    alerts.push(champion ? { key: 'started', values: { champion } } : { key: 'startedPlain' })
27  }
28  return alerts
29}
30
hooks/locales.ts 42 lines
1const en = {
2  found: 'Match found!',
3  accepted: 'Match accepted. Champion select.',
4  acceptedByXyra: 'Xyra accepted it. Champion select.',
5  champSelect: 'Champion select.',
6  requeued: 'Someone did not accept. You are still in the queue.',
7  notFormed: 'The match did not start.',
8  started: 'The game started: {champion}.',
9  startedPlain: 'The game started.',
10}
11
12export type MessageKey = keyof typeof en
13
14const es: Record<MessageKey, string> = {
15  found: '¡Partida encontrada!',
16  accepted: 'Partida aceptada. Selección de campeón.',
17  acceptedByXyra: 'Xyra la aceptó. Selección de campeón.',
18  champSelect: 'Selección de campeón.',
19  requeued: 'Alguien no aceptó. Sigues en cola.',
20  notFormed: 'La partida no se armó.',
21  started: 'La partida empezó: {champion}.',
22  startedPlain: 'La partida empezó.',
23}
24
25const LOCALES = {
26  en: { names: ['en', 'english', 'inglés'], messages: en },
27  es: { names: ['es', 'español', 'espanol', 'spanish', 'castellano'], messages: es },
28}
29
30export type Locale = keyof typeof LOCALES
31
32const BASE_LOCALE: Locale = 'en'
33
34export function localeOf(language: string | undefined): Locale {
35  const name = (language ?? '').trim().toLowerCase().split(/[-_\s]/)[0] ?? ''
36  const found = (Object.keys(LOCALES) as Locale[]).find(locale => LOCALES[locale].names.includes(name))
37  return found ?? BASE_LOCALE
38}
39
40export const translate = (locale: Locale, key: MessageKey, values: Record<string, string> = {}) =>
41  LOCALES[locale].messages[key].replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
42