SLOPSHOPPER

oni-halo

ONI Section Three RCON console for Project Reclaimer (Halo 3) dedicated servers, inside Claude Code

newpaneguardcommandtoasttool
v0.1.0MITupdated 2026-10-09viik2k/oni-halo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oni-halo
│ ┃ ONI · Section Three ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▲ OFFICE OF NAVAL INTELLIGENCE CONDITION GRE │ oni-halo │ │ ┃ SECTION THREE · REMOTE CONSOLE TERMINAL · ⏺ Read(src/auth.ts) │ ONI: sync failed: JSON Parse error: │ │ ┃ TOP SECRET ⎿ Read 6 lines │ Unexpected EOF │ │ ┃ halo · 0 stations · 0 personnel · synced ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ never ⎿ Added 2 lines, removed 1 line │ ┃ ── STATIONS ⏺ Bash(bun test) │ ┃ ─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ No uplink yet. Press r. │ ┃ ── ASSETS · — ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ───────────────────────────────────────── │ ┃ ── CONSOLE ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────────────────────────────────────── │ ┃ Commands go to the ▶ station · @all, @2 or › /halo │ ┃ @mlg first to aim elsewhere · help lists ⎿ oni-halo: ONI remote console open. │ ┃ them │ ┃ say Lobby restarts in 5 ⏎ Transmit │ ┃ │ ┃ [ Refresh ] [ Close ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ONI · Section Three
▲ OFFICE OF NAVAL INTELLIGENCE CONDITION GREEN SECTION THREE · REMOTE CONSOLE TERMINAL · TOP SECRET halo · 0 stations · 0 personnel · synced never ── STATIONS ─────────────────────────────────────────── No uplink yet. Press r. ── ASSETS · — ───────────────────────────────────────── ── CONSOLE ──────────────────────────────────────────── Commands go to the ▶ station · @all, @2 or @mlg first to aim elsewhere · help lists them say Lobby restarts in 5 ⏎ Transmit [ Refresh ] [ Close ]
README

oni-halo

oni-rcon's Office of Naval Intelligence console, as a Claude Code mod. Run your Project Reclaimer Halo 3 dedicated servers from inside Claude Code: a live Section Three pane, a /halo command, and a read-only RCON tool Claude can call to check on them for you.

Install

In a Claude Code terminal session:

/plugin install oni-halo --marketplace viik2k/oni-halo

Answer y to add the marketplace, pick a scope (user is the usual one), then set the two options it asks for:

OptionWhat it isDefault
hostssh alias or user@host of the box that runs your Reclaimer servers. Key auth, no promptshalo
operatorthe name the servers' admin log records for commands you sendfinn

Change them later in /config.

What it does

  • /halo opens the ONI pane:
  • STATIONS: every server, with its mode, map, phase and players. 1–9 selects one.
  • ASSETS: the selected server's roster, with team colour, service tag, score, K/D and admin flag. Player IPs are never shown.
  • CONSOLE: type any RCON command (help lists them). Prefix @all, @2, @mlg or a port to aim it somewhere other than the selected server.
  • r refreshes. The pane polls every 20 seconds while it's open.
  • The header reads CONDITION GREEN when every server answers, AMBER when one doesn't, and RED when the password is refused. On RED, polling stops, so it never runs into Reclaimer's five-wrong-passwords lockout.
  • /halo [@server] <command…> runs one command from the prompt and prints the reply. As in oni-rcon, the message for say, tell, kick and servername is the rest of the line.
  • mcp__oni-halo__rcon is a tool Claude can call with status, players, maps, modes, bans, vpn or help. Anything that changes a server is refused, so kicks, bans and broadcasts come from you, in the pane or through /halo. The admin log records Claude's commands as claude.

How it reaches the servers

Every command goes over ssh <host> to a small stdlib-only Python WebSocket client (hooks/helper.ts), which is fed to python3 on stdin, so nothing is installed on the game box. On that box, the client:

  • reads the RCON password from ~/reclaimer/.env (RECLAIMER_DEDICATED_RCON_PASSWORD);
  • finds the servers from the [[server]] blocks in ~/reclaimer/server/dedicated.toml;
  • talks to each one on 127.0.0.1.

The password never leaves the box, and RCON never has to be exposed to the network.

The game box needs python3 and that standard Reclaimer layout. A server you add to dedicated.toml shows up in the pane on its own.

Not here (use oni-rcon)

oni-rcon has these; this mod doesn't:

  • the live Intercepts feed (chat, kills and medals);
  • the Operations tab;
  • the Blacklist tab;
  • confirm dialogs before kicks and bans. In the pane, the command you type counts as the confirmation.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .

MIT © 2026 Arche Labs

Source 3 files
hooks/register.tsx 271 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Personnel, Station } from '../types'
5import { HELPER } from './helper'
6
7let HOST = 'halo' // ssh alias of the game box (userConfig `host`); the password never leaves it, read from ~/reclaimer/.env there
8let OPERATOR = 'finn' // the admin-log name for the pane and /halo (userConfig `operator`); the model's tool signs as 'claude'
9const PANE = 'oni-halo'
10const POLL_MS = 20_000
11
12// oni-rcon's palette
13const AMBER = '#D9A441', CYAN = '#4FC3D9', RED = '#E5484D', GREEN = '#5FB98A', DIM = '#5C6773', WHITE = '#D6DCE4', GOLD = '#FFD27A'
14const TEAM: Record<string, string> = { red: '#E5484D', blue: '#4C8DFF', green: '#4CC27A', orange: '#F08A24', purple: '#A066E0', gold: '#E8C547', brown: '#A0714A', pink: '#F07FB8' }
15const TEAMS = Object.keys(TEAM)
16const FREE_TEXT: Record<string, number> = { say: 0, tell: 1, kick: 1, servername: 0 } // last argument = rest of the line
17const READ_ONLY = new Set(['status', 'players', 'maps', 'modes', 'bans', 'vpn', 'help'])
18
19const stations = atom({ plugin: 'oni-halo', key: 'stations' } as const, [])
20const selected = atom({ plugin: 'oni-halo', key: 'selected' } as const, 0)
21const log = atom({ plugin: 'oni-halo', key: 'log' } as const, [])
22const isDenied = atom({ plugin: 'oni-halo', key: 'isDenied' } as const, false)
23const isBusy = atom({ plugin: 'oni-halo', key: 'isBusy' } as const, false)
24const synced = atom({ plugin: 'oni-halo', key: 'synced' } as const, '')
25
26type Reply = { port: number; command: string; server?: string; ok: boolean; text?: string; data?: any; denied?: boolean }
27
28/** A console line as its target (`@all`, `@2`, `@mlg`, `@49176`) and words; say/tell/kick/servername keep the rest of the line whole. */
29export function parseLine(line: string): { at?: string; words: string[] } {
30  let s = line.trim(), at: string | undefined
31  if (s.startsWith('@')) {
32    const end = s.search(/\s|$/)
33    at = s.slice(1, end)
34    s = s.slice(end).trim()
35  }
36  const token = /\s*(?:"([^"]*)"|(\S+))/y
37  const words: string[] = []
38  let pos = 0
39  const take = () => {
40    token.lastIndex = pos
41    const m = token.exec(s)
42    if (!m) return false
43    words.push(m[1] ?? m[2] ?? '')
44    pos = token.lastIndex
45    return true
46  }
47  take()
48  const lead = FREE_TEXT[(words[0] ?? '').toLowerCase()]
49  if (lead === undefined) {
50    while (take());
51    return { at, words }
52  }
53  while (words.length <= lead && take());
54  let rest = s.slice(pos).trim()
55  const q = rest[0]
56  if (rest.length > 1 && (q === '"' || q === "'") && rest.endsWith(q) && !rest.slice(1, -1).includes(q)) rest = rest.slice(1, -1)
57  if (rest) words.push(rest)
58  return { at, words }
59}
60
61/** The ports a target names: none means the selected station, `all` every one (0 = the box fans out). */
62export function pick(list: Station[], at: string | undefined, sel: number): number[] {
63  if (at === undefined) return sel ? [sel] : []
64  if (at.toLowerCase() === 'all') return [0]
65  const n = Number(at)
66  if (Number.isInteger(n) && n >= 1 && n <= list.length) return [list[n - 1]!.port]
67  if (Number.isInteger(n) && n > 1024) return [n]
68  return list.filter(s => s.name.toLowerCase().includes(at.toLowerCase())).map(s => s.port)
69}
70
71/** Names with the tag they all share cut off: "Arche Labs | OCE Slayer" -> "Slayer". */
72export function shortNames(names: string[]): string[] {
73  if (names.length < 2) return names
74  let p = names[0]!
75  for (const n of names) while (!n.startsWith(p)) p = p.slice(0, -1)
76  const cut = Math.max(p.lastIndexOf(' '), 0)
77  return names.map(n => n.slice(cut).replace(/^[\s|·:-]+/, '') || n)
78}
79
80const b64 = (s: string) => btoa(String.fromCharCode(...new TextEncoder().encode(s)))
81const clock = () => new Date().toTimeString().slice(0, 8)
82const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s.padEnd(n))
83const num = (v: unknown) => (typeof v === 'number' ? v : Number(v) || 0)
84
85async function rcon($: EngineInterface, by: string, batch: (string | number)[][]): Promise<Reply[]> {
86  const r = await $.process.run(
87    ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', HOST, 'python3', '-', b64(by), b64(JSON.stringify(batch))],
88    { stdin: HELPER, timeoutMs: 60_000 },
89  )
90  if (r.exitCode !== 0) throw new Error(r.stderr.trim().split('\n').pop() || `ssh exited ${r.exitCode}`)
91  return JSON.parse(r.stdout)
92}
93
94function personnel(p: any): Personnel {
95  const t = p.team
96  return {
97    id: String(p.id ?? ''), name: String(p.name ?? '?'), tag: String(p.service_tag ?? ''),
98    team: typeof t === 'number' ? TEAMS[t] ?? '' : t ? String(t).toLowerCase() : '',
99    score: num(p.score), kills: num(p.kills), deaths: num(p.deaths), isAdmin: !!p.admin,
100  }
101}
102
103let inFlight = false // ponytail: one sync at a time; a poll that lands mid-sync is skipped, not queued
104
105async function refresh($: EngineInterface) {
106  if (inFlight) return
107  inFlight = true
108  await update($, isBusy, () => true)
109  try {
110    const replies = await rcon($, OPERATOR, [[0, 'status'], [0, 'players']])
111    const byPort = new Map<number, Station>()
112    for (const r of replies) {
113      const s = byPort.get(r.port) ?? { port: r.port, name: r.server ?? String(r.port), isUp: r.ok, text: r.text ?? '', map: '', mode: '', phase: '', players: 0, max: 0, crew: [] }
114      if (r.command === 'status' && r.ok) Object.assign(s, { name: r.data?.name ?? s.name, map: r.data?.map ?? '', mode: r.data?.mode ?? '', phase: r.data?.phase ?? '', players: num(r.data?.players), max: num(r.data?.max_players) })
115      if (r.command === 'players' && r.ok) s.crew = (r.data?.players ?? []).map(personnel)
116      s.isUp &&= r.ok
117      if (!r.ok) s.text = r.text ?? 'no reply'
118      byPort.set(r.port, s)
119    }
120    const list = [...byPort.values()]
121    await update($, stations, () => list)
122    await update($, isDenied, () => replies.some(r => r.denied))
123    await update($, selected, sel => (list.some(s => s.port === sel) ? sel : list[0]?.port ?? 0))
124    await update($, synced, () => clock())
125  } catch (err) {
126    $.ui.toast(`ONI: sync failed: ${(err as Error).message}`)
127  } finally {
128    inFlight = false
129    await update($, isBusy, () => false)
130  }
131}
132
133/** Runs one console line and logs it; resolves the replies as text. */
134async function execute($: EngineInterface, by: string, line: string, readOnly = false): Promise<string> {
135  const { at, words } = parseLine(line)
136  if (!words.length) return 'Nothing to send.'
137  const cmd = words[0]!.toLowerCase()
138  if (readOnly && !READ_ONLY.has(cmd)) throw new Error(`'${cmd}' changes the server; Finn runs it from the ONI pane or /halo. Read-only here: ${[...READ_ONLY].join(', ')}.`)
139  const list = await read($, stations)
140  const ports = pick(list, at, await read($, selected))
141  if (!ports.length) throw new Error(at ? `No station matches @${at}.` : 'No station selected yet: refresh first, or name one with @.')
142  const replies = await rcon($, by, ports.map(p => [p, cmd, ...words.slice(1)]))
143  const names = new Map(list.map((s, i) => [s.port, shortNames(list.map(x => x.name))[i]!]))
144  const entries: Entry[] = replies.map(r => ({ at: clock(), where: names.get(r.port) ?? String(r.port), line: words.join(' '), isOk: r.ok, text: r.text ?? '' }))
145  await update($, log, l => [...l, ...entries].slice(-60))
146  if (!READ_ONLY.has(cmd)) void refresh($)
147  return entries.map(e => `[${e.where}] ${e.isOk ? '' : 'FAILED: '}${e.text}`).join('\n')
148}
149
150export const register: Register = (on, options) => {
151  HOST = String(options.host ?? HOST)
152  OPERATOR = String(options.operator ?? OPERATOR)
153  on('session.start', async ($, e, next) => {
154    await $.command.register({ name: 'halo', description: 'ONI RCON console for the Halo servers (no args: open the pane)', argumentHint: '[@server|@all] <command> [args]' })
155    await $.tool.register({
156      name: 'rcon',
157      description: 'Read-only RCON on the Project Reclaimer (Halo 3) dedicated servers behind the configured ssh host. `command` is one console line: status, players, maps, modes, bans, vpn or help. `server` is "all", a 1-based station number, a port, or part of a name; default the station selected in the ONI pane.',
158      inputSchema: { type: 'object', properties: { command: { type: 'string' }, server: { type: 'string' } }, required: ['command'] },
159    })
160    $.clock.every(POLL_MS, async () => {
161      if (await read($, isDenied)) return // never hammer a refused password: 5 in 10 min locks the address out
162      if ((await $.ui.panes()).some(p => p.id === PANE)) await refresh($)
163    })
164    return next(e)
165  })
166
167  on('command.run', { command: 'halo' }, async ($, e) => {
168    if (!e.args.trim()) {
169      await $.ui.open({ id: PANE, title: 'ONI · Section Three' })
170      void refresh($)
171      return { text: 'ONI remote console open.' }
172    }
173    if (!(await read($, stations)).length) await refresh($)
174    try {
175      return { text: await execute($, OPERATOR, e.args) }
176    } catch (err) {
177      return { text: (err as Error).message }
178    }
179  })
180
181  on('tool.call', { tool: 'mcp__oni-halo__rcon' }, async ($, e) => {
182    const { command = '', server } = e as unknown as { command?: string; server?: string }
183    try {
184      if (!(await read($, stations)).length) await refresh($)
185      return { result: await execute($, 'claude', (server ? `@${server.replace(/\s+/g, '')} ` : '') + command, true) }
186    } catch (err) {
187      return { deny: (err as Error).message }
188    }
189  }).catch(() => ({ deny: 'oni-halo: the RCON guard failed, so nothing was sent.' }))
190
191  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
192    const ui = $.ui.resolve(e)
193    const { Box, Text, Button } = ui
194    const width = Math.max(40, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
195    const list = await read($, stations)
196    const sel = await read($, selected)
197    const entries = await read($, log)
198    const denied = await read($, isDenied)
199    const busy = await read($, isBusy)
200    const names = shortNames(list.map(s => s.name))
201    const cur = list.find(s => s.port === sel)
202    const crew = list.reduce((n, s) => n + s.crew.length, 0)
203    const [cond, condColor] = denied ? ['RED · SIGN-IN REFUSED', RED] : list.some(s => !s.isUp) ? ['AMBER', AMBER] : ['GREEN', GREEN]
204    const rule = (label: string) => <Text color={DIM}>── <Text color={AMBER} bold>{label}</Text> {'─'.repeat(Math.max(0, width - label.length - 5))}</Text>
205    const nameW = Math.min(18, Math.max(8, ...names.map(n => n.length)))
206    const room = Math.max(3, (e.viewport?.rows ?? 30) - list.length - 16)
207
208    return (
209      <Box flexDirection="column">
210        <Box justifyContent="space-between">
211          <Text color={AMBER} bold>▲ OFFICE OF NAVAL INTELLIGENCE</Text>
212          <Text color={condColor} bold> CONDITION {cond} </Text>
213        </Box>
214        <Text color={DIM}>
215          SECTION THREE · REMOTE CONSOLE TERMINAL · <Text color={RED}>TOP SECRET</Text>
216        </Text>
217        <Text color={DIM}>
218          {HOST} · {list.length} stations · {crew} personnel · {busy ? <Text color={GOLD}>▒ SYNCING</Text> : `synced ${(await read($, synced)) || 'never'}`}
219        </Text>
220        {rule('STATIONS')}
221        {list.length === 0 && <Text color={DIM}>{busy ? 'Establishing uplink to the game box…' : 'No uplink yet. Press r.'}</Text>}
222        {list.map((s, i) => (
223          <Box key={`st${s.port}`}>
224            <Button key={`sel${s.port}`} plain hotkey={i < 9 ? String(i + 1) : undefined} label={`${s.port === sel ? '▶' : ' '} ${pad(names[i]!, nameW)}`} onPress={() => update($, selected, () => s.port)} />
225            <Text color={s.isUp ? GREEN : RED}> ● </Text>
226            {s.isUp ? (
227              <Text color={s.port === sel ? WHITE : DIM} wrap="truncate">
228                {pad(`${s.mode} on ${s.map}`, 26)} {pad(s.phase.replace(/_/g, ' '), 10)} <Text color={s.players ? CYAN : DIM}>{s.players}/{s.max}</Text>
229              </Text>
230            ) : (
231              <Text color={RED} wrap="truncate">{s.text}</Text>
232            )}
233          </Box>
234        ))}
235        {rule(`ASSETS · ${cur ? names[list.indexOf(cur)] : '—'}`)}
236        {cur && cur.crew.length === 0 && <Text color={DIM}>No personnel deployed.</Text>}
237        {cur?.crew.map(p => (
238          <Text key={`p${p.id}`} wrap="truncate">
239            <Text color={TEAM[p.team] ?? DIM}>█ </Text>
240            <Text color={GOLD}>{pad(p.tag, 5)}</Text>
241            <Text color={WHITE}>{pad(p.name, 18)}</Text>
242            <Text color={DIM}> score </Text><Text color={CYAN}>{String(p.score).padStart(3)}</Text>
243            <Text color={DIM}>  K/D </Text><Text color={WHITE}>{p.kills}/{p.deaths}</Text>
244            {p.isAdmin && <Text color={AMBER}>  ADMIN</Text>}
245            <Text color={DIM}>  {p.id.slice(0, 8)}</Text>
246          </Text>
247        ))}
248        {rule('CONSOLE')}
249        <Text color={DIM}>
250          Commands go to the ▶ station · <Text color={CYAN}>@all</Text>, <Text color={CYAN}>@2</Text> or <Text color={CYAN}>@mlg</Text> first to aim elsewhere · <Text color={CYAN}>help</Text> lists them
251        </Text>
252        {'Input' in ui && e.surface !== 'mobile' && (
253          <ui.Input key="console" placeholder="say Lobby restarts in 5" submitLabel="Transmit" onSubmit={line => void execute($, OPERATOR, line).catch(err => $.ui.toast(`ONI: ${(err as Error).message}`))} />
254        )}
255        {entries.slice(-room).map((l, i) => (
256          <Text key={`log${i}`} wrap="truncate">
257            <Text color={DIM}>{l.at} </Text>
258            <Text color={CYAN}>{pad(l.where, 10)} </Text>
259            <Text color={WHITE}>› {l.line} </Text>
260            <Text color={l.isOk ? GREEN : RED}>{l.isOk ? '✓' : '✗'} {l.text.split('\n')[0]}</Text>
261          </Text>
262        ))}
263        <Box gap={1} marginTop={1}>
264          <Button key="refresh" hotkey="r" label="Refresh" variant="primary" onPress={() => refresh($)} />
265          <Button key="close" role="dismiss" label="Close" onPress={() => $.ui.close({ id: PANE })} />
266        </Box>
267      </Box>
268    )
269  })
270}
271
hooks/helper.ts 67 lines
1// The RCON client run on the game box over ssh: stdlib only, so the game box needs nothing installed.
2export const HELPER = String.raw`import base64, json, os, re, socket, struct, sys
3
4# ponytail: stdlib WebSocket client so the game box needs nothing installed; argv: base64 by, base64 JSON [[port, cmd, *args], ...]
5def frame(s, d):
6    m, n = os.urandom(4), len(d)
7    h = bytes([0x81]) + (bytes([0x80 | n]) if n < 126 else bytes([0xFE]) + struct.pack(">H", n) if n < 65536 else bytes([0xFF]) + struct.pack(">Q", n))
8    s.sendall(h + m + bytes(b ^ m[i % 4] for i, b in enumerate(d)))
9
10def recv(s, f):
11    buf = b""
12    while True:
13        hd = f.read(2)
14        if len(hd) < 2: raise ConnectionError("connection closed")
15        n = hd[1] & 127
16        if n == 126: n = struct.unpack(">H", f.read(2))[0]
17        elif n == 127: n = struct.unpack(">Q", f.read(8))[0]
18        mask = f.read(4) if hd[1] & 128 else None
19        p = f.read(n)
20        if mask: p = bytes(b ^ mask[i % 4] for i, b in enumerate(p))
21        op = hd[0] & 15
22        if op == 8: raise ConnectionError("server closed the connection")
23        if op == 9: s.sendall(bytes([0x8A, 0x80 | len(p)]) + b"\0\0\0\0" + p); continue
24        if op in (1, 2, 0): buf += p
25        if hd[0] & 128 and op != 10 and buf:
26            return json.loads(buf)
27
28def password():
29    for line in open(os.path.expanduser("~/reclaimer/.env"), encoding="utf-8"):
30        if line.startswith("RECLAIMER_DEDICATED_RCON_PASSWORD="):
31            return line.split("=", 1)[1].strip().strip('"')
32    raise SystemExit("no RECLAIMER_DEDICATED_RCON_PASSWORD in ~/reclaimer/.env")
33
34def session(port, calls, by, pw):
35    out = []
36    try:
37        s = socket.create_connection(("127.0.0.1", port), 5); s.settimeout(10)
38        k = base64.b64encode(os.urandom(16)).decode()
39        s.sendall(f"GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {k}\r\nSec-WebSocket-Version: 13\r\n\r\n".encode())
40        f = s.makefile("rb")
41        if b" 101 " not in f.readline(): raise ConnectionError("that port doesn't speak RCON")
42        while f.readline() not in (b"\r\n", b""): pass
43        frame(s, json.dumps({"type": "auth", "password": pw}).encode())
44        a = recv(s, f)
45        if not a.get("ok"):  # never retried: 5 wrong passwords in 10 min lock the address out
46            return [{"port": port, "command": c[0], "ok": False, "denied": True, "text": a.get("error") or a.get("text") or "sign-in refused"} for c in calls]
47        for i, (cmd, *args) in enumerate(calls, 1):
48            frame(s, json.dumps({"type": "command", "command": cmd, "args": [str(x) for x in args], "id": i, "by": by}).encode())
49            while (r := recv(s, f)).get("type") != "reply" or r.get("id") != i: pass  # skip pushed events
50            out.append({"port": port, "command": cmd, "server": a.get("server"), **r})
51        s.close()
52    except (OSError, ValueError, ConnectionError) as e:
53        out += [{"port": port, "command": c[0], "ok": False, "text": str(e) or type(e).__name__} for c in calls[len(out):]]
54    return out
55
56def ports():  # every [[server]] port in dedicated.toml: a server added there shows up here with no edit
57    text = open(os.path.expanduser("~/reclaimer/server/dedicated.toml"), encoding="utf-8").read()
58    text = re.split(r"(?m)^\[\[server\]\]", text)[1:]  # at line start: the comments mention [[server]] too
59    return [int(m.group(1)) for b in text if (m := re.search(r"(?m)^rcon_port\s*=\s*(\d+)", b) or re.search(r"(?m)^port\s*=\s*(\d+)", b))]
60
61by, batch = (base64.b64decode(x).decode() for x in sys.argv[1:3])  # base64: survives the ssh remote shell
62pw, groups = password(), {}
63for port, *call in json.loads(batch):  # port 0 = every server
64    for p in ports() if int(port) == 0 else [int(port)]: groups.setdefault(p, []).append(call)
65print(json.dumps([r for port, calls in groups.items() for r in session(port, calls, by, pw)]))
66`
67
types/index.d.ts 10 lines
1export type Personnel = { id: string; name: string; tag: string; team: string; score: number; kills: number; deaths: number; isAdmin: boolean }
2export type Station = { port: number; name: string; isUp: boolean; text: string; map: string; mode: string; phase: string; players: number; max: number; crew: Personnel[] }
3export type Entry = { at: string; where: string; line: string; isOk: boolean; text: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'oni-halo': { stations: Station[]; selected: number; log: Entry[]; isDenied: boolean; isBusy: boolean; synced: string }
8  }
9}
10