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

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.
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:
| Option | What it is | Default |
|---|---|---|
host | ssh alias or user@host of the box that runs your Reclaimer servers. Key auth, no prompts | halo |
operator | the name the servers' admin log records for commands you send | finn |
Change them later in /config.
/halo opens the ONI pane:1–9 selects one.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./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.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:
~/reclaimer/.env (RECLAIMER_DEDICATED_RCON_PASSWORD);[[server]] blocks in ~/reclaimer/server/dedicated.toml;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.
oni-rcon has these; this mod doesn't:
claude --plugin-dir .
claude plugin validate .
claude plugin test .
MIT © 2026 Arche Labs
hooks/register.tsx 271 lines1import { 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}
271hooks/helper.ts 67 lines1// 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`
67types/index.d.ts 10 lines1export 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