A globe of everything your Claude session talks to: tool calls, sockets and bytes traced to hosts, countries and orgs, with a deny-list that blocks before a…

Where does your Claude session phone? orbit draws a globe in a pane with you as a red dot and an arc for every connection the session makes: the Anthropic stream breathing while Claude writes, a WebFetch landing in Ashburn, an MCP server talking to GitHub, a curl in a shell. Each arc is traced back to the tool that caused it, the host, the address, the org, the country and the bytes. It can also block: a deny-list enforced before a tool call runs.
Add the marketplace:
/plugin marketplace add jamubc/toolbox
Install the plugin:
/plugin install orbit@toolbox
Then, once: /orbit locate (or /config → Where you are) for the red dot, and /orbit geodb for the offline geo database that places connections.
| Command | What it does | |||
|---|---|---|---|---|
/orbit | Opens or closes the pane: the globe, the badge, the legend and the live log. | |||
/orbit all / /orbit session | Observe every Claude session on this machine (each its own color, with a legend) or this one only. | |||
| Click a log row | Highlights its arc in white and shows the details, with block this host. | |||
◀ ⌂ ▶ | Turn the globe; ⌂ centers it on you again. | |||
/orbit block <host> | Blocks a host (example.com takes its subdomains, *.cdn.net is a glob, an address works too), mcp:<server> an MCP server, or tool:<Tool> a tool. | |||
/orbit allow <host> | Allows one, which also whitelists it in allowlist mode. | |||
/orbit unblock <host> | Removes the rule. Rules are kept across sessions. | |||
/orbit rules | Lists the rules and the mode. | |||
| `/orbit mode off\ | denylist\ | allowlist\ | ask` | Watch only; block what is listed; block all but the allowed; or ask about each new host with a dialog (8 seconds, then deny). /config → What to do with a host you have not allowed sets the default. |
/orbit export [path] | Writes the trace as JSONL. The trace is also written as it goes to ~/.claude/orbit/traces/<session>.jsonl. | |||
| `/orbit replay [file\ | off]` | Lists the saved traces, or re-animates one on the globe with a scrubber: ⏮ ▶ ⏸ ⏭ and 1× to 16×. | ||
/orbit locate | Sets your location from your public address: one request, then an offline lookup. Or /orbit home 49.28,-123.12 Vancouver. | |||
/orbit geodb [country] | Downloads DB-IP Lite (city, ~130 MB, or country, ~10 MB) and ASN Lite into ~/.claude/orbit/geo. No account, no key. | |||
| `/orbit proxy on\ | off` | Starts the proxy layer for this session. /config → Start the proxy layer every session turns it on for every session. | ||
/orbit check | Which layers work here, and why not. | |||
/config → Show hosts and countries at the bottom of the terminal | Shows orbit · 6 hosts · 3 countries on the status line. |
Three layers feed one trace, in the order they are worth having:
tool.call hook sees WebFetch, WebSearch, every MCP tool and every Bash command before it runs: which tool, which host (URLs, git clone, ssh, curl, pip, nc...), when. Rules are enforced here, so a blocked call never runs and Claude reads why.lsof -i on macOS, ss -tnpi on Linux (or /proc without ss). This catches what the hooks cannot see: the Anthropic API itself, telemetry, child processes. Byte counts come from nettop on macOS and from ss on Linux. A socket that appears within a few seconds of a tool call that named a host is matched to it, so one row shows tool → host → address → bytes.HTTPS_PROXY for them. It records the CONNECT host, the address it connected to, bytes each way and how long the connection lived. It never decrypts anything: TLS passes through. A proxy the session already had is chained through.Beside those, the turn's own stream is tapped so the Anthropic arc pulses while Claude generates, with or without a socket in view.
Addresses are placed with an offline MaxMind-format database: DB-IP Lite by default (/orbit geodb, CC BY 4.0), or a GeoLite2 file you point /config → City database at. Lookups run on your machine; orbit makes no request per connection, ever. Cloudflare, Fastly, Akamai, public DNS and Anthropic's own ranges are marked ~ anycast, because where an anycast address answers is not where its owner is; Anthropic's API is drawn at the company's home and says so.
Your own location is manual first (/config → Where you are). /orbit locate is the one exception to "no requests": one call to api.ipify.org for your address, which the offline database then places (without a database it asks ipinfo.io once instead, and says so).
| Where you run Claude Code | Globe | Layers |
|---|---|---|
| kitty, Ghostty | Real pixels (kitty graphics protocol), 4 frames a second. Untested so far. | All |
| Any other terminal, tmux, ssh | Colored half-blocks, two pixels per character, 10 frames a second. | All |
| Claude desktop app, VS Code extension, mobile | The trace and the badge; no globe. | Tool layer; the rest needs the terminal |
| Layer | macOS | Linux | Elevated permissions |
|---|---|---|---|
| Tool | Yes | Yes | None |
| Process: sockets | lsof -i (ships with macOS) | ss (iproute2), or /proc | None for your own processes. Other users' processes need root, so observe-all sees your sessions only. |
| Process: bytes | nettop (ships with macOS) | ss -i counters (TCP only) | None. If nettop needs more than it has on your macOS version, arcs still draw without thickness. |
| Proxy | Node.js 18+ | Node.js 18+ | None. Only children started after it is on go through it; the Claude Code process read HTTPS_PROXY at launch and keeps its own route, which the process layer sees. |
| Geo | Node.js 18+ for the helper | Node.js 18+ | None. One download you ask for. |
| Blocking | Tool layer only | Tool layer only | None. Sockets are observed, never cut: a process that opens a connection without going through a tool call (an MCP server on its own, a child the shell left behind) is shown, not stopped. |
/orbit check says which of these apply on your machine.
/clear and /compact leave the pane, the trace and your rules where they were: /clear starts a new session in the same process, and orbit writes what it was showing into it.
ss from iproute2 (nearly always there). Node.js for the geo helper and the proxy.ps from the shell it runs in; if that fails /orbit check says so and only the tool layer records.ss -i, which has them for TCP only; UDP (DNS) shows without bytes. macOS nettop per-connection rows are parsed by column name; if your macOS version prints them differently, bytes stay at zero, and the format is in hooks/procs.ts.HTTPS_PROXY; the process layer covers both, without hostnames.ask mode has eight seconds, the budget a hook has to answer; with no answer the call is denied, and Claude is told to ask you.hooks/register.tsx wires the hooks, the command, the timers (frames at 10 Hz, polling at 0.5 Hz) and the helpers.hooks/tools.ts reads what a tool call reaches; hooks/rules.ts decides it.hooks/procs.ts parses ps, lsof, ss, /proc and nettop; hooks/model.ts folds sockets and proxy lines into events and matches them to tool calls.hooks/globe.ts draws the globe into hooks/canvas.ts pixels; hooks/png.ts encodes them with its own deflate for the Image path; hooks/land.ts is the land mask (docs/landmask.py makes it).hooks/trace.ts is the event shape, the arcs a moment draws, JSONL and replay.geo/geoip.mjs reads MaxMind-format databases and downloads DB-IP Lite; proxy/proxy.mjs is the proxy. Both are plain Node with no dependencies.ps, lsof, ss, nettop or /proc, for your own processes./orbit geodb (db-ip.com) and /orbit locate (api.ipify.org, or ipinfo.io without a database).~/.claude/orbit/traces (off with /config → Write the trace to disk every session) and rules to the plugin's store. Nothing leaves your machine.hooks/register.tsx 987 lines1// orbit: a globe of everything your Claude session talks to. Three capture layers feed one
2// trace: tool calls seen before they run (and blocked by your rules), the sockets of the
3// session's process tree, and an optional local proxy for the commands and MCP servers the
4// session starts. The pane draws the globe (pixels where the terminal can, half-blocks
5// elsewhere), the log, and a replay of any saved trace.
6
7import { atom, derive, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { OrbitAsk, OrbitCapabilities, OrbitEvent, OrbitHome, OrbitReplay, OrbitRule, OrbitSession } from '../types'
11import { Pixels } from './canvas'
12import { type GeoAnswer, formatIp, parseIp, placeOf } from './geo'
13import { type Scene, render } from './globe'
14import { Live, type Owner } from './model'
15import { type Actions, type GlobeBox, type Table, drawAsk, drawPane, folder } from './pane'
16import { encodePng } from './png'
17import {
18 PS_ARGV, SS_ARGV, countersInNettop, cwdsIn, lsofCwdArgv, lsofNetArgv, nettopArgv, procWalkArgv, sessionOf, sessionsIn,
19 socketsInLsof, socketsInProc, socketsInSs, treeOf, type Socket, type Counter,
20} from './procs'
21import { type Mode, MODES, type Rule, decide, describeRule, parseSubject, verdict, withRule, withoutRule } from './rules'
22import { intentOf, isTraced } from './tools'
23import { type Replay, type TraceEvent, arcsFor, eventsAt, fromJsonl, openReplay, stepReplay, summarize, toJsonl } from './trace'
24
25const PANE = 'orbit'
26const ASK_PANE = 'orbit-ask'
27const FRAME_MS = 100
28const IMAGE_EVERY = 3 // image frames every third tick
29const POLL_MS = 2000
30const GEO_MS = 2500
31const FLUSH_MS = 3000
32const LOG_MS = 10000
33const ASK_MS = 8000
34const CELL_W = 9 // pixels per cell in image mode
35const CELL_H = 18
36const DENIES_BEFORE_FALLBACK = 3
37
38type Options = {
39 location?: string
40 enforcement?: string
41 picture?: string
42 geoDatabase?: string
43 asnDatabase?: string
44 proxy?: boolean
45 fadeSeconds?: number
46 autoLog?: boolean
47 statusLine?: boolean
48 node?: string
49}
50
51const events = atom({ plugin: 'orbit', key: 'events' } as const, [] as OrbitEvent[])
52const rules = atom({ plugin: 'orbit', key: 'rules' } as const, [] as OrbitRule[])
53const mode = atom({ plugin: 'orbit', key: 'mode' } as const, 'denylist' as Mode)
54const scope = atom({ plugin: 'orbit', key: 'scope' } as const, 'session' as 'session' | 'all')
55const sessions = atom({ plugin: 'orbit', key: 'sessions' } as const, [] as OrbitSession[])
56const home = atom({ plugin: 'orbit', key: 'home' } as const, { lat: 0, lon: 0, label: '', source: 'none' } as OrbitHome)
57const selectedId = atom({ plugin: 'orbit', key: 'selectedId' } as const, '')
58const capabilities = atom({ plugin: 'orbit', key: 'capabilities' } as const, {
59 platform: '', tools: true, sockets: '', bytes: '', node: false, geo: 'missing', geoNote: '', proxy: 'off', proxyPort: 0, self: 0,
60} as OrbitCapabilities)
61const replayState = atom({ plugin: 'orbit', key: 'replay' } as const, null as OrbitReplay | null)
62const spin = atom({ plugin: 'orbit', key: 'spin' } as const, null as number | null)
63const isPaneOpen = atom({ plugin: 'orbit', key: 'isPaneOpen' } as const, false)
64const ask = atom({ plugin: 'orbit', key: 'ask' } as const, null as OrbitAsk | null)
65const renderer = atom({ plugin: 'orbit', key: 'renderer' } as const, 'cells' as 'image' | 'cells')
66const seeded = atom({ plugin: 'orbit', key: 'seeded' } as const, false)
67
68// /clear ends the session but not the process: the host's `$.state` starts over empty while this
69// module, its pane and its timers live on, so read straight from the host the trace, the home
70// point and the capabilities are suddenly blank. `seeded` is false exactly then, and before the
71// first `session.start`. `snapshot` is what the panes draw from, read in one go: the host's while
72// `seeded`, else what this process last saw; `write` puts the kept values back before the first
73// change after a /clear, and a render that finds `seeded` false schedules that.
74type Snapshot = {
75 events: OrbitEvent[]
76 rules: OrbitRule[]
77 mode: Mode
78 scope: 'session' | 'all'
79 sessions: OrbitSession[]
80 home: OrbitHome
81 selectedId: string
82 capabilities: OrbitCapabilities
83 replay: OrbitReplay | null
84 spin: number | null
85 isPaneOpen: boolean
86 ask: OrbitAsk | null
87 renderer: 'image' | 'cells'
88}
89const KEYS = ['events', 'rules', 'mode', 'scope', 'sessions', 'home', 'selectedId', 'capabilities', 'replay', 'spin', 'isPaneOpen', 'ask', 'renderer'] as const
90let kept: Snapshot = {
91 events: [], rules: [], mode: 'denylist', scope: 'session', sessions: [], home: { lat: 0, lon: 0, label: '', source: 'none' }, selectedId: '',
92 capabilities: { platform: '', tools: true, sockets: '', bytes: '', node: false, geo: 'missing', geoNote: '', proxy: 'off', proxyPort: 0, self: 0 },
93 replay: null, spin: null, isPaneOpen: false, ask: null, renderer: 'cells',
94}
95let wasLive: boolean | null = null // what the last read saw; null before the first
96const snapshot = derive(
97 [seeded, events, rules, mode, scope, sessions, home, selectedId, capabilities, replayState, spin, isPaneOpen, ask, renderer],
98 (isLive, events, rules, mode, scope, sessions, home, selectedId, capabilities, replay, spin, isPaneOpen, ask, renderer): Snapshot => {
99 wasLive = isLive
100 if (!isLive) return kept
101 kept = { events, rules, mode, scope, sessions, home, selectedId, capabilities, replay, spin, isPaneOpen, ask, renderer }
102 return kept
103 },
104)
105
106/** The one place the atoms are written: each key to its own, as the validator asks. */
107async function put<K extends keyof Snapshot>($: EngineInterface, key: K, value: Snapshot[K]): Promise<void> {
108 switch (key) {
109 case 'events': await update($, events, () => value as Snapshot['events']); break
110 case 'rules': await update($, rules, () => value as Snapshot['rules']); break
111 case 'mode': await update($, mode, () => value as Snapshot['mode']); break
112 case 'scope': await update($, scope, () => value as Snapshot['scope']); break
113 case 'sessions': await update($, sessions, () => value as Snapshot['sessions']); break
114 case 'home': await update($, home, () => value as Snapshot['home']); break
115 case 'selectedId': await update($, selectedId, () => value as Snapshot['selectedId']); break
116 case 'capabilities': await update($, capabilities, () => value as Snapshot['capabilities']); break
117 case 'replay': await update($, replayState, () => value as Snapshot['replay']); break
118 case 'spin': await update($, spin, () => value as Snapshot['spin']); break
119 case 'isPaneOpen': await update($, isPaneOpen, () => value as Snapshot['isPaneOpen']); break
120 case 'ask': await update($, ask, () => value as Snapshot['ask']); break
121 case 'renderer': await update($, renderer, () => value as Snapshot['renderer']); break
122 }
123}
124
125/** After a /clear (or at the first start), writes what this process kept back to the host. */
126let reseeding: Promise<void> | null = null
127function reseed($: EngineInterface): Promise<void> {
128 reseeding ??= (async () => {
129 try {
130 if (await read($, seeded)) return
131 for (const key of KEYS) await put($, key, kept[key])
132 await update($, seeded, () => true)
133 wasLive = true
134 } finally {
135 reseeding = null
136 }
137 })()
138 return reseeding
139}
140
141/** Changes one value from what `snapshot` reads, and keeps it here too. */
142async function write<K extends keyof Snapshot>($: EngineInterface, key: K, change: (now: Snapshot[K]) => Snapshot[K]): Promise<void> {
143 // `kept` is current once a read has seen the host live: only this module writes these values.
144 if (wasLive === null) await read($, snapshot)
145 if (!wasLive) await reseed($)
146 const next = change(kept[key])
147 kept = { ...kept, [key]: next }
148 await put($, key, next)
149}
150
151type View = { requestId: string; columns: number; rows: number; renderer: 'image' | 'cells' }
152
153// What only this process has: all of it starts over on a hot reload, and `session.start` refills it.
154const live = new Live()
155let options: Options = {}
156let views: View[] = []
157let tick = 0
158let self = 0
159let owner: Owner = { sessionPid: 0, session: 'this session', cmd: 'claude' }
160let ownerByPid = new Map<number, Owner>()
161let caps: OrbitCapabilities = { platform: '', tools: true, sockets: '', bytes: '', node: false, geo: 'missing', geoNote: '', proxy: 'off', proxyPort: 0, self: 0 }
162let currentScope: 'session' | 'all' = 'session'
163let currentMode: Mode = 'denylist'
164let currentRules: Rule[] = []
165let currentHome: OrbitHome = { lat: 0, lon: 0, label: '', source: 'none' }
166let currentSpin: number | null = null
167let currentSelected = ''
168let currentRenderer: 'image' | 'cells' = 'cells'
169let replay: Replay | null = null
170let lastReplayWrite = 0
171let generatingUntil = 0
172let isPolling = false
173let isLookingUp = false
174let lastFrameAt = 0
175let lastFrameTime = 0
176let lastLogAt = 0
177let imageDenies = 0
178let pendingAsk: { resolve: (answer: 'allow' | 'deny') => void } | null = null
179let sessionId = ''
180let homeDir = ''
181let geoFiles = { geo: '', asn: '' }
182let nodeBin = 'node'
183let proxy: { stop: () => void; logFile: string; lines: number; size: number; port: number; saved: ProxyEnv } | null = null
184let fadeMs = 25000
185let lastStatus = ''
186
187const now = () => Date.now()
188const orbitDir = () => `${homeDir}/.claude/orbit`
189
190async function run($: EngineInterface, argv: readonly string[], timeoutMs = 8000): Promise<string> {
191 try {
192 const res = await $.process.run(argv, { timeoutMs })
193 return res.stdout
194 } catch {
195 return ''
196 }
197}
198
199async function has($: EngineInterface, command: string): Promise<boolean> {
200 const out = await run($, ['sh', '-c', `command -v "$1"`, 'orbit', command], 4000)
201 return out.trim() !== ''
202}
203
204/** The shell prints its pid, then `exec`s `ps` under that same pid, so the table holds the way up. */
205async function findSelf($: EngineInterface): Promise<number> {
206 const sh = await run($, ['sh', '-c', `echo $$; exec ${PS_ARGV.join(' ')}`], 5000)
207 const [pid = '', ...table] = sh.split('\n')
208 return sessionOf(table.join('\n'), Number(pid))
209}
210
211function parseLocation(text: string): OrbitHome | null {
212 const m = /^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*(.*)$/.exec(text)
213 if (!m) return null
214 const lat = Number(m[1])
215 const lon = Number(m[2])
216 if (!(Math.abs(lat) <= 90 && Math.abs(lon) <= 180)) return null
217 return { lat, lon, label: m[3]!.trim(), source: 'config' }
218}
219
220async function setCaps($: EngineInterface, fields: Partial<OrbitCapabilities>): Promise<void> {
221 caps = { ...caps, ...fields }
222 await write($, 'capabilities', () => caps)
223}
224
225async function checkCapabilities($: EngineInterface): Promise<void> {
226 const platform = (await run($, ['uname', '-s'], 4000)).trim() || 'unknown'
227 const isMac = platform === 'Darwin'
228 const [lsof, ss, nettop, node] = await Promise.all([has($, 'lsof'), has($, 'ss'), has($, 'nettop'), has($, nodeBin)])
229 let sockets = ''
230 if (isMac) sockets = lsof ? 'lsof' : ''
231 else sockets = ss ? 'ss' : (await $.fs.exists('/proc/net/tcp')) ? 'proc' : lsof ? 'lsof' : ''
232 const bytes = isMac ? (nettop ? 'nettop' : '') : ss ? 'ss' : ''
233 if (self === 0) self = await findSelf($)
234 owner = { sessionPid: self, session: 'this session', cmd: 'claude' }
235 await setCaps($, { platform, sockets, bytes, node, self })
236 await checkGeo($)
237}
238
239async function checkGeo($: EngineInterface): Promise<void> {
240 const geoPath = options.geoDatabase || `${orbitDir()}/geo/dbip-city-lite.mmdb`
241 const countryPath = `${orbitDir()}/geo/dbip-country-lite.mmdb`
242 const asnPath = options.asnDatabase || `${orbitDir()}/geo/dbip-asn-lite.mmdb`
243 const found = (await $.fs.exists(geoPath)) ? geoPath : !options.geoDatabase && (await $.fs.exists(countryPath)) ? countryPath : ''
244 geoFiles = { geo: found, asn: (await $.fs.exists(asnPath)) ? asnPath : '' }
245 if (!caps.node) await setCaps($, { geo: 'no-node', geoNote: '' })
246 else if (found) await setCaps($, { geo: 'ready', geoNote: found.endsWith('country-lite.mmdb') ? 'country level' : '' })
247 else await setCaps($, { geo: 'missing', geoNote: options.geoDatabase ? `${options.geoDatabase} not found.` : '' })
248}
249
250function labelOf(pid: number, all: readonly OrbitSession[]): string {
251 if (pid === self) return 'this session'
252 const s = all.find(x => x.pid === pid)
253 return s ? `${folder(s.cwd)} ${s.tty}` : `pid ${pid}`
254}
255
256async function poll($: EngineInterface): Promise<void> {
257 if (isPolling) return
258 isPolling = true
259 try {
260 const ps = await run($, PS_ARGV, 5000)
261 if (ps === '') return
262 if (self === 0) {
263 self = await findSelf($)
264 owner = { sessionPid: self, session: 'this session', cmd: 'claude' }
265 await setCaps($, { self })
266 }
267 let all: OrbitSession[] = []
268 let roots = [self]
269 if (currentScope === 'all') {
270 all = sessionsIn(ps)
271 if (self && !all.some(s => s.pid === self)) all.push({ pid: self, tty: '', cwd: '', isWorking: false })
272 if (all.length) {
273 const cwds = caps.platform === 'Darwin'
274 ? cwdsIn(await run($, lsofCwdArgv(all.map(s => s.pid)), 5000))
275 : new Map((await run($, ['sh', '-c', 'for p in "$@"; do echo "$p $(readlink /proc/$p/cwd 2>/dev/null)"; done', 'orbit', ...all.map(s => String(s.pid))], 5000))
276 .split('\n').map(l => l.split(' ')).filter(p => p.length === 2).map(p => [Number(p[0]), p[1]!] as const))
277 all = all.map(s => ({ ...s, cwd: cwds.get(s.pid) ?? '' }))
278 }
279 roots = all.map(s => s.pid)
280 const before = (await read($, snapshot)).sessions
281 if (JSON.stringify(before) !== JSON.stringify(all)) await write($, 'sessions', () => all)
282 }
283 const next = new Map<number, Owner>()
284 for (const root of roots) {
285 if (!root) continue
286 const label = labelOf(root, all)
287 for (const [pid, cmd] of treeOf(ps, root)) next.set(pid, { sessionPid: root, session: label, cmd })
288 }
289 ownerByPid = next
290 const pids = [...ownerByPid.keys()]
291 if (pids.length === 0 || caps.sockets === '') return
292 let sockets: Socket[] = []
293 if (caps.sockets === 'lsof') sockets = socketsInLsof(await run($, lsofNetArgv(pids), 8000))
294 else if (caps.sockets === 'ss') sockets = socketsInSs(await run($, SS_ARGV, 8000), new Set(pids))
295 else if (caps.sockets === 'proc') sockets = socketsInProc(await run($, procWalkArgv(pids), 8000))
296 let counters: Counter[] = []
297 if (caps.bytes === 'nettop' && sockets.some(s => s.remote !== '')) counters = countersInNettop(await run($, nettopArgv(pids), 8000))
298 live.observeSockets(sockets, pid => ownerByPid.get(pid), now(), counters)
299 if (proxy) await readProxyLog($)
300 } catch (err) {
301 $.ui.log(`orbit: poll failed: ${(err as Error).message}`, { to: 'debug' })
302 } finally {
303 isPolling = false
304 }
305}
306
307async function lookupPending($: EngineInterface): Promise<void> {
308 if (isLookingUp || caps.geo !== 'ready' || !geoFiles.geo) return
309 const ips = live.takePending(40)
310 if (ips.length === 0) return
311 isLookingUp = true
312 try {
313 const out = await run($, [nodeBin, `${$.plugin.root}/geo/geoip.mjs`, 'lookup', geoFiles.geo, geoFiles.asn || '-', ...ips], 20000)
314 const answers: GeoAnswer[] = []
315 for (const line of out.split('\n')) {
316 if (line.trim() === '') continue
317 try {
318 answers.push(JSON.parse(line))
319 } catch {}
320 }
321 if (answers.length === 0) {
322 for (const ip of ips) live.pendingIps.add(ip)
323 return
324 }
325 live.applyGeo(answers)
326 } finally {
327 isLookingUp = false
328 }
329}
330
331async function flush($: EngineInterface): Promise<void> {
332 if (!live.isDirty) return
333 live.isDirty = false
334 await write($, 'events', () => live.events.slice(-600))
335 if (options.statusLine) {
336 const s = summarize(live.events.filter(e => currentScope === 'all' || e.sessionPid === self || e.sessionPid === 0))
337 const text = `orbit · ${s.hosts.length} host${s.hosts.length === 1 ? '' : 's'} · ${s.countries.length} ${s.countries.length === 1 ? 'country' : 'countries'}${s.blocked ? ` · ${s.blocked} blocked` : ''}`
338 if (text !== lastStatus) {
339 lastStatus = text
340 $.ui.status(text)
341 }
342 }
343 if (options.autoLog !== false && homeDir && sessionId && now() - lastLogAt > LOG_MS) {
344 lastLogAt = now()
345 await $.fs.write(`${orbitDir()}/traces/${sessionId}.jsonl`, toJsonl(live.events)).catch(() => {})
346 }
347}
348
349const visibleEvents = (): TraceEvent[] => (currentScope === 'all' ? live.events : live.events.filter(e => e.sessionPid === self || e.sessionPid === 0))
350
351function sceneFor(view: View, t: number, arcsAt: number, shown: readonly TraceEvent[]): Scene {
352 const isImage = view.renderer === 'image'
353 const width = isImage ? view.columns * CELL_W : view.columns
354 const height = isImage ? view.rows * CELL_H : view.rows * 2
355 const hasHome = currentHome.source !== 'none'
356 const homePoint = { lat: currentHome.lat, lon: currentHome.lon }
357 const arcs = hasHome ? arcsFor(shown, { now: arcsAt, fadeMs, home: homePoint, bySession: currentScope === 'all', selectedId: currentSelected, isGenerating: t < generatingUntil }) : []
358 const dots = hasHome ? [] : shown.filter(e => e.place && (e.place.lat || e.place.lon)).map(e => ({ lat: e.place!.lat, lon: e.place!.lon, color: 0xffa726, ring: -1 }))
359 return {
360 width,
361 height,
362 center: { lat: Math.max(-70, Math.min(70, hasHome ? currentHome.lat : 20)), lon: (hasHome ? currentHome.lon : -30) + (currentSpin ?? 0) },
363 time: t,
364 home: hasHome ? homePoint : null,
365 arcs,
366 dots,
367 isQuantized: !isImage,
368 hasGrid: true,
369 }
370}
371
372function paint(view: View, scene: Scene): { cells?: string; png?: string } {
373 const px = render(scene)
374 if (view.renderer === 'image') return { png: encodePng(px.toRgba(0x000000), scene.width, scene.height).toBase64() }
375 return { cells: px.toCells() }
376}
377
378async function frame($: EngineInterface): Promise<void> {
379 tick += 1
380 const t = now()
381 const dt = lastFrameTime ? t - lastFrameTime : FRAME_MS
382 lastFrameTime = t
383 if (replay) {
384 const stepped = stepReplay(replay, dt)
385 if (stepped !== replay) {
386 replay = stepped
387 if (t - lastReplayWrite > 400 || !replay.isPlaying) {
388 lastReplayWrite = t
389 await write($, 'replay', () => summaryOf(replay!))
390 }
391 }
392 }
393 if (views.length === 0) return
394 const shown = replay ? eventsAt(replay, replay.position) : visibleEvents()
395 const arcsAt = replay ? replay.position : t
396 const isAnimating = (replay?.isPlaying ?? false) || t < generatingUntil || shown.some(e => e.status === 'open' || arcsAt - e.last < fadeMs)
397 if (!isAnimating && t - lastFrameAt < 2000) return
398 lastFrameAt = t
399 await Promise.all(
400 views.map(async view => {
401 if (view.renderer === 'image' && tick % IMAGE_EVERY !== 0) return
402 const scene = sceneFor(view, t, arcsAt, shown)
403 const painted = paint(view, scene)
404 const res = await $.ui.blit(
405 painted.png !== undefined
406 ? { requestId: view.requestId, key: 'globe', source: { png: painted.png } }
407 : { requestId: view.requestId, key: 'globe', cells: painted.cells! },
408 )
409 if (res.deny === undefined) {
410 if (view.renderer === 'image') imageDenies = 0
411 return
412 }
413 if (/mount/i.test(res.deny)) {
414 views = views.filter(v => v !== view)
415 return
416 }
417 if (view.renderer === 'image') {
418 $.ui.log(`orbit: image frame refused: ${res.deny}`, { to: 'debug' })
419 imageDenies += 1
420 if (imageDenies >= DENIES_BEFORE_FALLBACK) {
421 imageDenies = 0
422 currentRenderer = 'cells'
423 views = views.filter(v => v !== view)
424 await write($, 'renderer', () => 'cells')
425 $.ui.toast('This terminal cannot show the globe as pixels; drawing it in colored blocks instead.')
426 }
427 }
428 }),
429 )
430}
431
432const summaryOf = (r: Replay): OrbitReplay => ({ file: r.file, count: r.events.length, start: r.start, end: r.end, position: r.position, speed: r.speed, isPlaying: r.isPlaying })
433
434// The words are what the /config picker shows, so a row there explains itself;
435// the engine has already turned anything else into the default before we run.
436async function pickRenderer($: EngineInterface): Promise<'image' | 'cells'> {
437 const choice = (options.picture ?? '').toLowerCase()
438 if (choice.startsWith('always real')) return 'image'
439 if (choice.startsWith('always colored')) return 'cells'
440 const isRelayed = (await $.env.get('TMUX')) !== undefined || (await $.env.get('SSH_CONNECTION')) !== undefined || (await $.env.get('SSH_TTY')) !== undefined
441 if (isRelayed) return 'cells'
442 const term = (await $.env.get('TERM')) ?? ''
443 const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
444 const isKitty = (await $.env.get('KITTY_WINDOW_ID')) !== undefined
445 return isKitty || term.includes('kitty') || term.includes('ghostty') || program === 'ghostty' ? 'image' : 'cells'
446}
447
448async function saveRules($: EngineInterface, next: Rule[]): Promise<void> {
449 currentRules = next
450 await $.store.set('rules', next)
451 await write($, 'rules', () => next)
452}
453
454async function setHome($: EngineInterface, next: OrbitHome): Promise<void> {
455 currentHome = next
456 await write($, 'home', () => next)
457 if (next.source === 'lookup') await $.store.set('home', next)
458}
459
460async function askUser($: EngineInterface, subject: { kind: 'host' | 'mcp'; subject: string }, tool: string, summary: string): Promise<'allow' | 'deny'> {
461 if (pendingAsk) return 'deny'
462 const question: OrbitAsk = { id: `${now()}`, tool, kind: subject.kind, subject: subject.subject, summary }
463 await write($, 'ask', () => question)
464 const opened = await $.ui.open({ id: ASK_PANE, title: 'orbit: allow this connection?', focus: true, closeOnEscape: true, holdToasts: true, rows: 7 })
465 if (!opened.isPlaced) $.ui.toast(`orbit: ${tool} wants ${subject.subject}: widen the terminal to answer, or it is denied in 8 s.`)
466 const answer = await new Promise<'allow' | 'deny'>(resolve => {
467 pendingAsk = { resolve }
468 $.clock.after(ASK_MS, () => resolve('deny'))
469 })
470 pendingAsk = null
471 await write($, 'ask', () => null)
472 await $.ui.close({ id: ASK_PANE }).catch(() => {})
473 return answer
474}
475
476async function readProxyLog($: EngineInterface): Promise<void> {
477 if (!proxy) return
478 let text = ''
479 try {
480 text = await $.fs.read(proxy.logFile)
481 } catch {
482 return
483 }
484 if (text.length < proxy.size) proxy.lines = 0 // the helper emptied it
485 proxy.size = text.length
486 const lines = text.split('\n').filter(l => l.trim() !== '')
487 const fresh = lines.slice(proxy.lines)
488 proxy.lines = lines.length
489 if (fresh.length) live.observeProxy(fresh, owner, now())
490}
491
492type ProxyEnv = { HTTPS_PROXY?: string; HTTP_PROXY?: string; https_proxy?: string; http_proxy?: string }
493
494/** Points the children started from now on at the local proxy; the names are spelled out so the validator lists them. */
495async function applyProxyEnv($: EngineInterface, url: string): Promise<void> {
496 await $.env.set('HTTPS_PROXY', url)
497 await $.env.set('HTTP_PROXY', url)
498 await $.env.set('https_proxy', url)
499 await $.env.set('http_proxy', url)
500}
501
502async function restoreProxyEnv($: EngineInterface, saved: ProxyEnv): Promise<void> {
503 await $.env.set('HTTPS_PROXY', saved.HTTPS_PROXY)
504 await $.env.set('HTTP_PROXY', saved.HTTP_PROXY)
505 await $.env.set('https_proxy', saved.https_proxy)
506 await $.env.set('http_proxy', saved.http_proxy)
507}
508
509async function startProxy($: EngineInterface): Promise<void> {
510 if (proxy || !caps.node) {
511 if (!caps.node) await setCaps($, { proxy: 'error' })
512 return
513 }
514 const logFile = `${orbitDir()}/proxy-${sessionId || 'session'}.jsonl`
515 await $.fs.write(logFile, '')
516 const saved = {
517 HTTPS_PROXY: await $.env.get('HTTPS_PROXY'),
518 HTTP_PROXY: await $.env.get('HTTP_PROXY'),
519 https_proxy: await $.env.get('https_proxy'),
520 http_proxy: await $.env.get('http_proxy'),
521 }
522 const upstream = saved.HTTPS_PROXY ?? saved.https_proxy ?? ''
523 await setCaps($, { proxy: 'starting' })
524 const child = $.process.spawn({ argv: [nodeBin, `${$.plugin.root}/proxy/proxy.mjs`, logFile], env: upstream ? { ORBIT_UPSTREAM_PROXY: upstream } : {} })
525 const state = { stop: () => void child.return(undefined as never), logFile, lines: 0, size: 0, port: 0, saved }
526 proxy = state
527 void (async () => {
528 let buffer = ''
529 try {
530 for await (const chunk of child) {
531 if (chunk.stream !== 'stdout') continue
532 buffer += chunk.text
533 const at = buffer.indexOf('\n')
534 if (at < 0) continue
535 const line = buffer.slice(0, at)
536 buffer = buffer.slice(at + 1)
537 try {
538 const msg = JSON.parse(line)
539 if (msg.type === 'ready' && proxy === state) {
540 state.port = Number(msg.port)
541 const url = `http://127.0.0.1:${state.port}`
542 await applyProxyEnv($, url)
543 await setCaps($, { proxy: 'on', proxyPort: state.port })
544 }
545 } catch {}
546 }
547 } catch (err) {
548 $.ui.log(`orbit: proxy helper: ${(err as Error).message}`, { to: 'debug' })
549 }
550 if (proxy === state) {
551 proxy = null
552 await setCaps($, { proxy: 'error', proxyPort: 0 })
553 await restoreProxyEnv($, saved)
554 }
555 })()
556}
557
558async function stopProxy($: EngineInterface): Promise<void> {
559 if (!proxy) return
560 const state = proxy
561 proxy = null
562 state.stop()
563 await restoreProxyEnv($, state.saved)
564 await setCaps($, { proxy: 'off', proxyPort: 0 })
565}
566
567async function downloadGeo($: EngineInterface, level: 'city' | 'country'): Promise<string> {
568 if (!caps.node) return 'The geo helper needs Node.js: install node or set its path in /config → Node path.'
569 if (caps.geo === 'downloading') return 'Already downloading.'
570 const dir = `${orbitDir()}/geo`
571 await setCaps($, { geo: 'downloading', geoNote: 'starting…' })
572 const child = $.process.spawn({ argv: [nodeBin, `${$.plugin.root}/geo/geoip.mjs`, 'download', dir, level] })
573 void (async () => {
574 let buffer = ''
575 let error = ''
576 try {
577 for await (const chunk of child) {
578 if (chunk.stream !== 'stdout') continue
579 buffer += chunk.text
580 let at = buffer.indexOf('\n')
581 while (at >= 0) {
582 const line = buffer.slice(0, at)
583 buffer = buffer.slice(at + 1)
584 at = buffer.indexOf('\n')
585 try {
586 const msg = JSON.parse(line)
587 if (msg.type === 'progress') await setCaps($, { geoNote: `${msg.name}: ${Math.round(msg.bytes / 1048576)} MB${msg.total ? ` of ${Math.round(msg.total / 1048576)}` : ''}` })
588 else if (msg.type === 'done') await setCaps($, { geoNote: `${msg.name} ${msg.month} ready` })
589 else if (msg.type === 'error') error = String(msg.error)
590 } catch {}
591 }
592 }
593 } catch (err) {
594 error = (err as Error).message
595 }
596 await checkGeo($)
597 if (caps.geo === 'ready') {
598 $.ui.toast('orbit: geo database ready; placing connections.')
599 for (const e of live.events) if (e.ip && (!e.place || (!e.place.lat && !e.place.lon))) live.pendingIps.add(e.ip)
600 } else {
601 await setCaps($, { geo: 'error', geoNote: error || 'download failed' })
602 $.ui.toast(`orbit: geo download failed: ${error || 'no file'}`)
603 }
604 })()
605 return `Downloading DB-IP Lite (${level}) and ASN Lite into ${dir}; the pane shows progress. Licensed CC BY 4.0 by db-ip.com.`
606}
607
608async function locate($: EngineInterface): Promise<string> {
609 try {
610 if (caps.geo === 'ready' && geoFiles.geo) {
611 const res = await $.http.fetch('https://api.ipify.org?format=json')
612 const ipText = String(JSON.parse(res.text).ip ?? '')
613 const ip = parseIp(ipText)
614 if (!ip) return 'Could not read your public address from api.ipify.org.'
615 const out = await run($, [nodeBin, `${$.plugin.root}/geo/geoip.mjs`, 'lookup', geoFiles.geo, geoFiles.asn || '-', formatIp(ip)], 20000)
616 const answer = JSON.parse(out.split('\n').find(l => l.trim()) ?? '{}') as GeoAnswer
617 const place = placeOf(answer, ip)
618 if (!place.lat && !place.lon) return `Your address ${formatIp(ip)} is not in the database; set /config → Where you are by hand.`
619 await setHome($, { lat: place.lat, lon: place.lon, label: [place.city, place.country].filter(Boolean).join(', '), source: 'lookup' })
620 return `Home set to ${currentHome.label} (${place.lat}, ${place.lon}) from your public address, looked up offline. Saved for next time.`
621 }
622 const res = await $.http.fetch('https://ipinfo.io/json')
623 const info = JSON.parse(res.text) as { loc?: string; city?: string; country?: string }
624 const parsed = parseLocation(info.loc ?? '')
625 if (!parsed) return 'ipinfo.io gave no location; set /config → Where you are by hand.'
626 await setHome($, { ...parsed, label: [info.city, info.country].filter(Boolean).join(', '), source: 'lookup' })
627 return `Home set to ${currentHome.label} (${parsed.lat}, ${parsed.lon}) by one lookup at ipinfo.io (no database yet). Saved for next time.`
628 } catch (err) {
629 return `Lookup failed: ${(err as Error).message}`
630 }
631}
632
633function actionsFor($: EngineInterface): Actions {
634 return {
635 setScope: s => void setScope($, s),
636 select: id => {
637 currentSelected = currentSelected === id ? '' : id
638 void write($, 'selectedId', () => currentSelected)
639 },
640 spin: delta => {
641 currentSpin = delta === null ? null : (currentSpin ?? 0) + delta
642 void write($, 'spin', () => currentSpin)
643 },
644 replayToggle: () => {
645 if (!replay) return
646 replay = { ...replay, isPlaying: !replay.isPlaying, position: replay.position >= replay.end ? replay.start : replay.position }
647 void write($, 'replay', () => summaryOf(replay!))
648 },
649 replaySeek: direction => {
650 if (!replay) return
651 const step = (replay.end - replay.start) / 20
652 replay = { ...replay, position: Math.max(replay.start, Math.min(replay.end, replay.position + direction * step)) }
653 void write($, 'replay', () => summaryOf(replay!))
654 },
655 replaySpeed: () => {
656 if (!replay) return
657 replay = { ...replay, speed: replay.speed >= 16 ? 1 : replay.speed * 2 }
658 void write($, 'replay', () => summaryOf(replay!))
659 },
660 replayClose: () => {
661 replay = null
662 void write($, 'replay', () => null)
663 },
664 downloadGeo: level => void downloadGeo($, level).then(text => $.ui.toast(text)),
665 exportTrace: () => void exportTrace($, '').then(text => $.ui.toast(text)),
666 block: subject => void addRule($, subject, 'deny').then(text => $.ui.toast(text)),
667 allow: subject => void addRule($, subject, 'allow').then(text => $.ui.toast(text)),
668 unrule: rule => void saveRules($, withoutRule(currentRules, rule.kind, rule.pattern)).then(() => $.ui.toast(`Removed: ${describeRule(rule)}`)),
669 clear: () => {
670 live.load([])
671 live.flows.clear()
672 live.isDirty = true
673 void flush($)
674 },
675 }
676}
677
678async function setScope($: EngineInterface, s: 'session' | 'all'): Promise<void> {
679 currentScope = s
680 await write($, 'scope', () => s)
681 void poll($)
682}
683
684async function addRule($: EngineInterface, subjectText: string, action: 'allow' | 'deny'): Promise<string> {
685 const parsed = parseSubject(subjectText)
686 if (!parsed) return 'Name a host (example.com, *.example.com, 1.2.3.4), mcp:<server> or tool:<Tool>.'
687 const rule: Rule = { kind: parsed.kind, pattern: parsed.pattern, action, at: now() }
688 await saveRules($, withRule(currentRules, rule))
689 const hint = currentMode === 'off' ? ' Enforcement is off: set /config → What to do with a host you have not allowed or /orbit mode denylist to apply it.' : ''
690 return `${action === 'deny' ? 'Blocking' : 'Allowing'} ${rule.kind}:${rule.pattern}.${hint}`
691}
692
693async function exportTrace($: EngineInterface, pathText: string): Promise<string> {
694 const path = pathText || `${orbitDir()}/traces/${sessionId || 'trace'}-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`
695 const shown = visibleEvents()
696 await $.fs.write(path, toJsonl(shown))
697 return `Wrote ${shown.length} events to ${path}`
698}
699
700async function openReplayFile($: EngineInterface, pathText: string): Promise<string> {
701 const dir = `${orbitDir()}/traces`
702 if (pathText === '') {
703 let files: { name: string; mtimeMs: number }[] = []
704 try {
705 files = (await $.fs.list(dir)).filter(f => f.name.endsWith('.jsonl')).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 10)
706 } catch {}
707 if (files.length === 0) return `No traces in ${dir} yet. Traces are written there while the session runs (/config → Write the trace).`
708 return `Traces in ${dir}:\n${files.map(f => ` ${f.name}`).join('\n')}\n/orbit replay <name> plays one.`
709 }
710 const path = pathText.includes('/') ? pathText : `${dir}/${pathText}`
711 let text = ''
712 try {
713 text = await $.fs.read(path)
714 } catch (err) {
715 return `Cannot read ${path}: ${(err as Error).message}`
716 }
717 const loaded = openReplay(path, fromJsonl(text))
718 if (!loaded) return `${path} holds no events.`
719 replay = loaded
720 await write($, 'replay', () => summaryOf(loaded))
721 if (!((await read($, snapshot)).isPaneOpen)) {
722 await $.ui.open({ id: PANE, title: 'Orbit' })
723 await write($, 'isPaneOpen', () => true)
724 }
725 return `Replaying ${loaded.events.length} events from ${path} at ${loaded.speed}×.`
726}
727
728async function checkReport($: EngineInterface): Promise<string> {
729 await checkCapabilities($)
730 const c = caps
731 const lines = [
732 `Platform: ${c.platform}${c.self ? ` · this session is pid ${c.self}` : ' · could not find this session in ps'}`,
733 `1. Tool layer: on (tool.call hook) · enforcement ${currentMode} · ${currentRules.length} rule${currentRules.length === 1 ? '' : 's'}`,
734 `2. Process layer: ${c.sockets ? `on, sockets via ${c.sockets}` : 'off: no lsof/ss/proc source found'}${c.bytes ? ` · bytes via ${c.bytes}` : ' · no byte counts'}`,
735 `3. Proxy layer: ${c.proxy === 'on' ? `on at 127.0.0.1:${c.proxyPort}` : c.proxy === 'error' ? 'failed to start' : 'off (/config → Start the proxy layer every session, or /orbit proxy on)'}`,
736 `Geo: ${c.geo === 'ready' ? `ready (${geoFiles.geo}${geoFiles.asn ? ' + ASN' : ', no ASN db'})` : c.geo === 'no-node' ? 'needs Node.js' : c.geo === 'downloading' ? `downloading ${c.geoNote}` : 'no database: /orbit geodb'}`,
737 `Home: ${currentHome.source === 'none' ? 'unset (/config → Where you are, or /orbit locate)' : `${currentHome.label || ''} ${currentHome.lat}, ${currentHome.lon} (${currentHome.source})`}`,
738 `Picture: ${currentRenderer === 'image' ? 'real pixels' : 'colored blocks'}`,
739 ]
740 return lines.join('\n')
741}
742
743export const register: Register = (on, opts) => {
744 options = (opts ?? {}) as Options
745 fadeMs = Math.max(2, Number(options.fadeSeconds) || 25) * 1000
746 nodeBin = options.node || 'node'
747 currentMode = MODES.includes(options.enforcement as Mode) ? (options.enforcement as Mode) : 'denylist'
748
749 on('session.start', async ($, e, next) => {
750 const result = await next(e)
751 if (!options.statusLine) $.ui.status(undefined)
752 await $.command.register({
753 name: 'orbit',
754 description: 'A globe of what this session talks to. /orbit (pane), all|session, block|allow|unblock <host|mcp:server>, rules, mode <off|denylist|allowlist|ask>, export, replay, locate, home <lat,lon>, geodb, proxy on|off, check, clear',
755 })
756 homeDir = (await $.env.get('HOME')) ?? ''
757 sessionId = await $.session.id().catch(() => '')
758
759 await reseed($)
760 const held = await read($, snapshot)
761 live.load(held.events)
762 const stored = (await $.store.get('rules')) as Rule[] | undefined
763 currentRules = Array.isArray(stored) ? stored : []
764 await write($, 'rules', () => currentRules)
765 await write($, 'mode', () => currentMode)
766 currentScope = held.scope
767 currentSpin = held.spin
768 currentSelected = held.selectedId
769 const configured = parseLocation(options.location ?? '')
770 const remembered = (await $.store.get('home')) as OrbitHome | undefined
771 currentHome = configured ?? (remembered && remembered.source === 'lookup' ? remembered : { lat: 0, lon: 0, label: '', source: 'none' })
772 await write($, 'home', () => currentHome)
773 currentRenderer = e.surface === 'terminal' ? await pickRenderer($) : 'cells'
774 await write($, 'renderer', () => currentRenderer)
775 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
776 await write($, 'isPaneOpen', () => isOpen)
777 await write($, 'ask', () => null)
778 replay = null
779 await write($, 'replay', () => null)
780
781 if (e.isInteractive) {
782 void (async () => {
783 await checkCapabilities($)
784 if (options.proxy) await startProxy($)
785 await poll($)
786 })().catch(err => $.ui.log(`orbit: start: ${(err as Error).message}`, { to: 'debug' }))
787 $.clock.every(FRAME_MS, () => void frame($).catch(() => {}))
788 $.clock.every(POLL_MS, () => void poll($).catch(() => {}))
789 $.clock.every(GEO_MS, () => void lookupPending($).catch(() => {}))
790 $.clock.every(FLUSH_MS, () => void flush($).catch(() => {}))
791 }
792 return result
793 })
794
795 // /clear: the pane, the trace and the timers stay up, so what they show is written back as soon
796 // as the host's state is the new session's (now, or from the next event if that comes later).
797 on('session.end', async ($, e, next) => {
798 const result = await next(e)
799 if (e.reason === 'clear') await reseed($).catch(() => {})
800 return result
801 })
802
803 on('tool.call', async ($, e, next) => {
804 const tool = String(e.tool)
805 if (!isTraced(tool)) return next(e)
806 const intent = intentOf(e as any)
807 if (intent.kind === 'none') return next(e)
808 const decisions = decide(currentRules, currentMode, intent)
809 let v = verdict(decisions)
810 if (v?.action === 'ask' && (v.kind === 'host' || v.kind === 'mcp')) {
811 const answer = await askUser($, { kind: v.kind, subject: v.subject }, tool, intent.summary)
812 v = answer === 'deny' ? { ...v, action: 'deny' } : undefined
813 }
814 if (v?.action === 'deny') {
815 const why = v.rule ? describeRule(v.rule) : currentMode === 'allowlist' ? `not on the allow list (${v.kind}:${v.subject})` : `denied when asked (${v.kind}:${v.subject})`
816 live.recordTool(intent, owner, now(), 'blocked', why)
817 await flush($)
818 return { deny: `orbit blocked this call: ${why}. /orbit allow ${v.kind}:${v.subject} lets it through.` }
819 }
820 const recorded = live.recordTool(intent, owner, now(), 'open')
821 void flush($)
822 const ran = await next(e)
823 const bytes = ran.deny === undefined && typeof ran.text === 'string' ? ran.text.length : 0
824 live.finishTool(recorded.map(r => r.id), now(), ran.deny !== undefined || ran.isError === true, bytes)
825 return ran
826 })
827
828 on('turn.step', async function* ($, e, next) {
829 const sees = caps.sockets === '' || caps.sockets === 'proc'
830 const host = (() => {
831 try {
832 return new URL(String((e as any).baseUrl ?? '')).hostname
833 } catch {
834 return 'api.anthropic.com'
835 }
836 })()
837 if (sees) live.recordStream(owner, host, now())
838 generatingUntil = now() + 2000
839 let bytes = 0
840 try {
841 for await (const chunk of next(e)) {
842 generatingUntil = now() + 1500
843 if (chunk.kind === 'text') bytes += chunk.text.length
844 yield chunk
845 }
846 } finally {
847 generatingUntil = now() + 300
848 if (sees) live.closeStream(owner, now(), bytes)
849 }
850 })
851
852 on('command.run', { command: 'orbit' }, async ($, e) => {
853 await reseed($)
854 const [word = '', ...rest] = e.args.trim().split(/\s+/)
855 const arg = rest.join(' ')
856 switch (word) {
857 case '': {
858 if ((await read($, snapshot)).isPaneOpen) {
859 await $.ui.close({ id: PANE })
860 return { text: 'Orbit closed.' }
861 }
862 const opened = await $.ui.open({ id: PANE, title: 'Orbit' })
863 await write($, 'isPaneOpen', () => true)
864 return { text: opened.isPlaced ? 'Orbit opened.' : 'Orbit opened; widen the terminal to see it.' }
865 }
866 case 'all':
867 case 'session':
868 await setScope($, word)
869 return { text: word === 'all' ? 'Observing every Claude session on this machine.' : 'Observing this session only.' }
870 case 'block':
871 return { text: await addRule($, arg, 'deny') }
872 case 'allow':
873 return { text: await addRule($, arg, 'allow') }
874 case 'unblock':
875 case 'unrule': {
876 const parsed = parseSubject(arg)
877 if (!parsed) return { text: 'Name the rule: /orbit unblock example.com' }
878 await saveRules($, withoutRule(currentRules, parsed.kind, parsed.pattern))
879 return { text: `Removed any rule for ${parsed.kind}:${parsed.pattern}.` }
880 }
881 case 'rules':
882 return { text: currentRules.length ? `Enforcement: ${currentMode}\n${currentRules.map(r => ` ${describeRule(r)}`).join('\n')}` : `Enforcement: ${currentMode}. No rules yet: /orbit block <host|mcp:server|tool:Tool>.` }
883 case 'mode': {
884 if (!MODES.includes(arg as Mode)) return { text: `Modes: ${MODES.join(', ')}. Currently ${currentMode}.` }
885 currentMode = arg as Mode
886 await write($, 'mode', () => currentMode)
887 return { text: `Enforcement: ${currentMode}${currentMode === 'ask' ? ' (a dialog asks about each new host; 8 seconds, then deny)' : ''}. /config → What to do with a host you have not allowed sets the default.` }
888 }
889 case 'export':
890 return { text: await exportTrace($, arg) }
891 case 'replay':
892 if (arg === 'off' || arg === 'close') {
893 replay = null
894 await write($, 'replay', () => null)
895 return { text: 'Replay closed; live again.' }
896 }
897 return { text: await openReplayFile($, arg) }
898 case 'locate':
899 return { text: await locate($) }
900 case 'home': {
901 const parsed = parseLocation(arg)
902 if (!parsed) return { text: 'Give a latitude and longitude: /orbit home 49.28,-123.12 Vancouver' }
903 await setHome($, { ...parsed, source: 'lookup' })
904 return { text: `Home set to ${parsed.lat}, ${parsed.lon}${parsed.label ? ` (${parsed.label})` : ''}. Saved for next time; /config → Where you are overrides it.` }
905 }
906 case 'geodb':
907 return { text: await downloadGeo($, arg === 'country' ? 'country' : 'city') }
908 case 'proxy':
909 if (arg === 'on') {
910 await startProxy($)
911 return { text: caps.node ? 'Starting the local proxy; commands and MCP servers started from now on go through it.' : 'The proxy helper needs Node.js.' }
912 }
913 if (arg === 'off') {
914 await stopProxy($)
915 return { text: 'Proxy stopped; HTTPS_PROXY restored.' }
916 }
917 return { text: `Proxy is ${caps.proxy}${caps.proxyPort ? ` on 127.0.0.1:${caps.proxyPort}` : ''}. /orbit proxy on|off.` }
918 case 'check':
919 return { text: await checkReport($) }
920 case 'clear':
921 actionsFor($).clear()
922 return { text: 'Trace cleared.' }
923 default:
924 return { text: 'Usage: /orbit · all | session · block|allow|unblock <host|mcp:server|tool:Tool> · rules · mode <off|denylist|allowlist|ask> · export [path] · replay [file|off] · locate · home <lat,lon> · geodb [country] · proxy on|off · check · clear' }
925 }
926 })
927
928 on('ui.close', async ($, e, next) => {
929 const result = await next(e)
930 if (e.id === PANE) {
931 await write($, 'isPaneOpen', () => false)
932 views = views.filter(v => v.requestId !== PANE)
933 }
934 if (e.id === ASK_PANE) pendingAsk?.resolve('deny')
935 return result
936 })
937
938 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
939 // Drawn from what this process kept after a /clear; the host gets it back off the render.
940 if (!(await read($, seeded))) $.clock.after(0, () => void reseed($).catch(() => {}))
941 const { events: shownEvents, mode: m, scope: s, home: h, selectedId: sel, capabilities: c, replay: r, sessions: all, renderer: rend, spin: sp } = await read($, snapshot)
942 const listed = replay ? eventsAt(replay, replay.position) : s === 'all' ? shownEvents : shownEvents.filter(ev => ev.sessionPid === c.self || ev.sessionPid === 0)
943 let globe: GlobeBox | null = null
944 if (e.surface === 'terminal') {
945 const width = e.props.bodyColumns
946 const sideBySide = width >= 96
947 const columns = Math.max(16, Math.min(sideBySide ? 48 : width - 2, 64))
948 const rows = Math.max(8, Math.round(columns / 2))
949 const view: View = { requestId: PANE, columns, rows, renderer: rend }
950 views = [...views.filter(v => v.requestId !== PANE), view]
951 const scene = sceneFor(view, now(), replay ? replay.position : now(), listed as TraceEvent[])
952 const painted = paint(view, scene)
953 globe = { columns, rows, renderer: rend, cells: painted.cells ?? '', png: painted.png ?? '' }
954 lastFrameAt = now()
955 }
956 return drawPane($.ui.resolve(e) as Table, {
957 surface: e.surface,
958 bodyColumns: e.props.bodyColumns,
959 viewportRows: e.viewport?.rows ?? 40,
960 events: listed as TraceEvent[],
961 summary: summarize(listed as TraceEvent[]),
962 scope: s,
963 mode: m,
964 rules: currentRules,
965 home: h,
966 selectedId: sel,
967 capabilities: c,
968 replay: r,
969 sessions: all,
970 globe,
971 isGenerating: now() < generatingUntil,
972 spin: sp,
973 }, actionsFor($))
974 })
975
976 on('ui.render', { component: 'Pane', requestId: ASK_PANE }, async ($, e) => {
977 if (!(await read($, seeded))) $.clock.after(0, () => void reseed($).catch(() => {}))
978 const question = (await read($, snapshot)).ask
979 const { Text } = $.ui.resolve(e)
980 if (!question) return <Text dimColor>Nothing to ask.</Text>
981 return drawAsk($.ui.resolve(e) as Table, question, (answer, isAlways) => {
982 if (isAlways) void saveRules($, withRule(currentRules, { kind: question.kind, pattern: question.subject, action: answer, at: now() }))
983 pendingAsk?.resolve(answer)
984 })
985 })
986}
987hooks/canvas.ts 130 lines1// Pixels and cells. A `Pixels` buffer is drawn into at any resolution; it packs into a `Raster`'s
2// cells as half-blocks (two pixels per cell, the top as foreground, the bottom as background) or
3// into RGBA bytes for an `Image`. Cells are little-endian u32 triplets [codePoint, fg, bg].
4
5export const DEFAULT_COLOR = 0x01000000 // the terminal's own color
6const HALF_BLOCK = 0x2580
7
8export class Pixels {
9 readonly rgb: Int32Array // 0xRRGGBB per pixel, -1 for transparent (the terminal's background)
10
11 constructor(
12 readonly width: number,
13 readonly height: number,
14 ) {
15 this.rgb = new Int32Array(width * height).fill(-1)
16 }
17
18 set(x: number, y: number, color: number): void {
19 const px = Math.round(x)
20 const py = Math.round(y)
21 if (px < 0 || py < 0 || px >= this.width || py >= this.height) return
22 this.rgb[py * this.width + px] = color
23 }
24
25 get(x: number, y: number): number {
26 if (x < 0 || y < 0 || x >= this.width || y >= this.height) return -1
27 return this.rgb[y * this.width + x]!
28 }
29
30 /** A line of pixels; `thickness` above 1 draws neighbours too. */
31 line(x0: number, y0: number, x1: number, y1: number, color: number, thickness = 1): void {
32 const n = Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))) || 1
33 for (let i = 0; i <= n; i++) {
34 const x = x0 + ((x1 - x0) * i) / n
35 const y = y0 + ((y1 - y0) * i) / n
36 this.set(x, y, color)
37 if (thickness >= 2) {
38 this.set(x + 1, y, color)
39 this.set(x, y + 1, color)
40 }
41 if (thickness >= 3) {
42 this.set(x - 1, y, color)
43 this.set(x, y - 1, color)
44 }
45 }
46 }
47
48 /** Half-block cells for a Raster `columns` wide and `rows` tall; the buffer is `columns × 2·rows`. */
49 toCells(): string {
50 const columns = this.width
51 const rows = Math.ceil(this.height / 2)
52 const words = new Uint32Array(columns * rows * 3)
53 for (let r = 0; r < rows; r++) {
54 for (let c = 0; c < columns; c++) {
55 const top = this.get(c, r * 2)
56 const bottom = this.get(c, r * 2 + 1)
57 const i = (r * columns + c) * 3
58 if (top < 0 && bottom < 0) {
59 words[i] = 0x20
60 words[i + 1] = DEFAULT_COLOR
61 words[i + 2] = DEFAULT_COLOR
62 } else {
63 words[i] = HALF_BLOCK
64 words[i + 1] = top < 0 ? DEFAULT_COLOR : top
65 words[i + 2] = bottom < 0 ? DEFAULT_COLOR : bottom
66 }
67 }
68 }
69 return new Uint8Array(words.buffer).toBase64()
70 }
71
72 /** RGBA bytes, transparent pixels as `background`. */
73 toRgba(background = 0x000000): Uint8Array {
74 const out = new Uint8Array(this.width * this.height * 4)
75 for (let i = 0; i < this.rgb.length; i++) {
76 const c = this.rgb[i]! < 0 ? background : this.rgb[i]!
77 out[i * 4] = (c >> 16) & 0xff
78 out[i * 4 + 1] = (c >> 8) & 0xff
79 out[i * 4 + 2] = c & 0xff
80 out[i * 4 + 3] = 0xff
81 }
82 return out
83 }
84}
85
86/** A grid of text cells, for a Raster that carries glyphs (labels over a globe). */
87export class Canvas {
88 readonly cells: Uint32Array
89
90 constructor(
91 readonly rows: number,
92 readonly cols: number,
93 ) {
94 this.cells = new Uint32Array(rows * cols * 3)
95 for (let i = 0; i < rows * cols; i++) this.cells.set([0x20, DEFAULT_COLOR, DEFAULT_COLOR], i * 3)
96 }
97
98 put(r: number, c: number, s: string, fg: number, bg = DEFAULT_COLOR): void {
99 if (r < 0 || r >= this.rows) return
100 let x = c
101 for (const ch of s) {
102 if (x >= 0 && x < this.cols) this.cells.set([ch.codePointAt(0)!, fg, bg], (r * this.cols + x) * 3)
103 x++
104 }
105 }
106
107 line(r: number): string {
108 let s = ''
109 for (let c = 0; c < this.cols; c++) s += String.fromCodePoint(this.cells[(r * this.cols + c) * 3] ?? 0x20)
110 return s
111 }
112
113 encode(): string {
114 return new Uint8Array(this.cells.buffer).toBase64()
115 }
116}
117
118/** Mixes two 0xRRGGBB colors: `t` 0 is `a`, 1 is `b`. */
119export function mix(a: number, b: number, t: number): number {
120 const k = Math.max(0, Math.min(1, t))
121 const ch = (shift: number) => Math.round(((a >> shift) & 0xff) * (1 - k) + ((b >> shift) & 0xff) * k)
122 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
123}
124
125/** Scales a color toward black: `k` 1 keeps it, 0 is black. */
126export const dim = (color: number, k: number): number => mix(0x000000, color, k)
127
128/** `#rrggbb` for a Text color prop. */
129export const hex = (c: number) => `#${(c & 0xffffff).toString(16).padStart(6, '0')}`
130hooks/geo.ts 234 lines1// Addresses and where they are. Nothing here makes a network request: anycast and CDN ranges are
2// a bundled table, and the offline database is read by geo/geoip.mjs on the host.
3
4export type Place = {
5 lat: number
6 lon: number
7 country: string // ISO 3166-1 alpha-2, '' when unknown
8 city: string
9 org: string // the network's name or ASN, '' when unknown
10 /** The address belongs to an anycast or CDN network, so the place is where its edge answered, if that. */
11 isAnycast: boolean
12 /** Who runs the anycast network, when known. */
13 anycast: string
14}
15
16export const UNKNOWN_PLACE: Place = { lat: 0, lon: 0, country: '', city: '', org: '', isAnycast: false, anycast: '' }
17
18/** Well-known anycast and CDN prefixes, as the operators publish them; the place of one is its edge. */
19export const ANYCAST: readonly { prefix: string; name: string }[] = [
20 // Anthropic (AS399358)
21 { prefix: '160.79.104.0/21', name: 'Anthropic' },
22 // Cloudflare (https://www.cloudflare.com/ips-v4)
23 { prefix: '173.245.48.0/20', name: 'Cloudflare' },
24 { prefix: '103.21.244.0/22', name: 'Cloudflare' },
25 { prefix: '103.22.200.0/22', name: 'Cloudflare' },
26 { prefix: '103.31.4.0/22', name: 'Cloudflare' },
27 { prefix: '141.101.64.0/18', name: 'Cloudflare' },
28 { prefix: '108.162.192.0/18', name: 'Cloudflare' },
29 { prefix: '190.93.240.0/20', name: 'Cloudflare' },
30 { prefix: '188.114.96.0/20', name: 'Cloudflare' },
31 { prefix: '197.234.240.0/22', name: 'Cloudflare' },
32 { prefix: '198.41.128.0/17', name: 'Cloudflare' },
33 { prefix: '162.158.0.0/15', name: 'Cloudflare' },
34 { prefix: '104.16.0.0/13', name: 'Cloudflare' },
35 { prefix: '104.24.0.0/14', name: 'Cloudflare' },
36 { prefix: '172.64.0.0/13', name: 'Cloudflare' },
37 { prefix: '131.0.72.0/22', name: 'Cloudflare' },
38 { prefix: '1.1.1.0/24', name: 'Cloudflare DNS' },
39 { prefix: '1.0.0.0/24', name: 'Cloudflare DNS' },
40 { prefix: '2606:4700::/32', name: 'Cloudflare' },
41 { prefix: '2803:f800::/32', name: 'Cloudflare' },
42 { prefix: '2405:b500::/32', name: 'Cloudflare' },
43 { prefix: '2405:8100::/32', name: 'Cloudflare' },
44 { prefix: '2a06:98c0::/29', name: 'Cloudflare' },
45 { prefix: '2c0f:f248::/32', name: 'Cloudflare' },
46 // Fastly (https://api.fastly.com/public-ip-list)
47 { prefix: '23.235.32.0/20', name: 'Fastly' },
48 { prefix: '43.249.72.0/22', name: 'Fastly' },
49 { prefix: '103.244.50.0/24', name: 'Fastly' },
50 { prefix: '103.245.222.0/23', name: 'Fastly' },
51 { prefix: '103.245.224.0/24', name: 'Fastly' },
52 { prefix: '104.156.80.0/20', name: 'Fastly' },
53 { prefix: '140.248.64.0/18', name: 'Fastly' },
54 { prefix: '140.248.128.0/17', name: 'Fastly' },
55 { prefix: '146.75.0.0/17', name: 'Fastly' },
56 { prefix: '151.101.0.0/16', name: 'Fastly' },
57 { prefix: '157.52.64.0/18', name: 'Fastly' },
58 { prefix: '167.82.0.0/17', name: 'Fastly' },
59 { prefix: '167.82.128.0/20', name: 'Fastly' },
60 { prefix: '167.82.160.0/20', name: 'Fastly' },
61 { prefix: '167.82.224.0/20', name: 'Fastly' },
62 { prefix: '172.111.64.0/18', name: 'Fastly' },
63 { prefix: '185.31.16.0/22', name: 'Fastly' },
64 { prefix: '199.27.72.0/21', name: 'Fastly' },
65 { prefix: '199.232.0.0/16', name: 'Fastly' },
66 { prefix: '2a04:4e40::/32', name: 'Fastly' },
67 { prefix: '2a04:4e42::/32', name: 'Fastly' },
68 // Google public DNS and Quad9
69 { prefix: '8.8.8.0/24', name: 'Google DNS' },
70 { prefix: '8.8.4.0/24', name: 'Google DNS' },
71 { prefix: '9.9.9.0/24', name: 'Quad9 DNS' },
72 // Akamai's main edge blocks
73 { prefix: '23.192.0.0/11', name: 'Akamai' },
74 { prefix: '104.64.0.0/10', name: 'Akamai' },
75 { prefix: '184.24.0.0/13', name: 'Akamai' },
76 { prefix: '2.16.0.0/13', name: 'Akamai' },
77 { prefix: '95.100.0.0/15', name: 'Akamai' },
78]
79
80/** Hosts whose operator is known without a lookup, by suffix. */
81export const KNOWN_HOSTS: readonly { suffix: string; org: string }[] = [
82 { suffix: 'anthropic.com', org: 'Anthropic' },
83 { suffix: 'claude.ai', org: 'Anthropic' },
84 { suffix: 'claude.com', org: 'Anthropic' },
85 { suffix: 'statsig.com', org: 'Statsig (Anthropic telemetry)' },
86 { suffix: 'sentry.io', org: 'Sentry' },
87 { suffix: 'github.com', org: 'GitHub' },
88 { suffix: 'githubusercontent.com', org: 'GitHub' },
89 { suffix: 'npmjs.org', org: 'npm' },
90 { suffix: 'pypi.org', org: 'PyPI' },
91 { suffix: 'pythonhosted.org', org: 'PyPI' },
92 { suffix: 'googleapis.com', org: 'Google' },
93 { suffix: 'google.com', org: 'Google' },
94 { suffix: 'duckduckgo.com', org: 'DuckDuckGo' },
95 { suffix: 'wikipedia.org', org: 'Wikimedia' },
96 { suffix: 'amazonaws.com', org: 'Amazon Web Services' },
97 { suffix: 'cloudfront.net', org: 'Amazon CloudFront' },
98 { suffix: 'db-ip.com', org: 'DB-IP' },
99]
100
101export const orgOfHost = (host: string): string => {
102 const h = host.toLowerCase()
103 return KNOWN_HOSTS.find(k => h === k.suffix || h.endsWith('.' + k.suffix))?.org ?? ''
104}
105
106/** 16 bytes for an IPv4 (mapped) or IPv6 address, or null. */
107export function parseIp(text: string): Uint8Array | null {
108 const s = text.trim().replace(/^\[|\]$/g, '')
109 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) {
110 const parts = s.split('.').map(Number)
111 if (parts.some(p => p > 255)) return null
112 const out = new Uint8Array(16)
113 out[10] = 0xff
114 out[11] = 0xff
115 out.set(parts, 12)
116 return out
117 }
118 if (!s.includes(':')) return null
119 const zone = s.indexOf('%')
120 const body = zone >= 0 ? s.slice(0, zone) : s
121 const halves = body.split('::')
122 if (halves.length > 2) return null
123 const words = (part: string): number[] | null => {
124 if (part === '') return []
125 const out: number[] = []
126 for (const w of part.split(':')) {
127 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(w)) {
128 const v4 = parseIp(w)
129 if (!v4) return null
130 out.push((v4[12]! << 8) | v4[13]!, (v4[14]! << 8) | v4[15]!)
131 } else if (/^[0-9a-f]{1,4}$/i.test(w)) out.push(parseInt(w, 16))
132 else return null
133 }
134 return out
135 }
136 const head = words(halves[0]!)
137 const tail = halves.length === 2 ? words(halves[1]!) : []
138 if (!head || !tail) return null
139 const missing = 8 - head.length - tail.length
140 if (missing < 0 || (halves.length === 1 && missing !== 0)) return null
141 const all = [...head, ...new Array(missing).fill(0), ...tail]
142 const out = new Uint8Array(16)
143 all.forEach((w, i) => {
144 out[i * 2] = w >> 8
145 out[i * 2 + 1] = w & 0xff
146 })
147 return out
148}
149
150export const isIpv4 = (ip: Uint8Array): boolean => ip.slice(0, 10).every(b => b === 0) && ip[10] === 0xff && ip[11] === 0xff
151
152/** The address as text: dotted for IPv4, compressed hex for IPv6. */
153export function formatIp(ip: Uint8Array): string {
154 if (isIpv4(ip)) return `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}`
155 const words = Array.from({ length: 8 }, (_, i) => ((ip[i * 2]! << 8) | ip[i * 2 + 1]!).toString(16))
156 let best = -1
157 let bestLength = 0
158 for (let i = 0; i < 8; i++) {
159 let j = i
160 while (j < 8 && words[j] === '0') j++
161 if (j - i > bestLength) {
162 best = i
163 bestLength = j - i
164 }
165 }
166 if (bestLength < 2) return words.join(':')
167 return `${words.slice(0, best).join(':')}::${words.slice(best + bestLength).join(':')}`
168}
169
170export function inPrefix(ip: Uint8Array, prefix: string): boolean {
171 const [base, bitsText] = prefix.split('/')
172 const net = parseIp(base ?? '')
173 if (!net) return false
174 let bits = Number(bitsText)
175 if (isIpv4(net)) bits += 96
176 if (isIpv4(net) !== isIpv4(ip)) return false
177 for (let i = 0; i < 16 && bits > 0; i++, bits -= 8) {
178 const mask = bits >= 8 ? 0xff : (0xff << (8 - bits)) & 0xff
179 if (((ip[i]! ^ net[i]!) & mask) !== 0) return false
180 }
181 return true
182}
183
184/** Loopback, link-local, private, multicast and unspecified addresses, which have no place. */
185export function isLocal(ip: Uint8Array): boolean {
186 const local4 = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16', '100.64.0.0/10', '0.0.0.0/8', '224.0.0.0/4']
187 const local6 = ['::1/128', '::/128', 'fe80::/10', 'fc00::/7', 'ff00::/8']
188 return (isIpv4(ip) ? local4 : local6).some(p => inPrefix(ip, p))
189}
190
191export const anycastOf = (ip: Uint8Array): string => ANYCAST.find(a => inPrefix(ip, a.prefix))?.name ?? ''
192
193/** A host as the rules and the log spell it: lowercase, no port, no trailing dot. */
194export function normalizeHost(host: string): string {
195 let h = host.trim().toLowerCase().replace(/\.$/, '')
196 if (h.startsWith('[')) {
197 const end = h.indexOf(']')
198 return end > 0 ? h.slice(1, end) : h
199 }
200 const colon = h.lastIndexOf(':')
201 if (colon > 0 && !h.slice(0, colon).includes(':') && /^\d+$/.test(h.slice(colon + 1))) h = h.slice(0, colon)
202 return h
203}
204
205/** Great-circle distance in kilometres between two places. */
206export function distanceKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
207 const rad = Math.PI / 180
208 const dLat = (b.lat - a.lat) * rad
209 const dLon = (b.lon - a.lon) * rad
210 const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2
211 return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(h)))
212}
213
214/** One line of geo/geoip.mjs's lookup output. */
215export type GeoAnswer = { ip: string; country?: string; city?: string; lat?: number; lon?: number; org?: string; error?: string }
216
217export function placeOf(answer: GeoAnswer | undefined, ip: Uint8Array): Place {
218 const anycast = anycastOf(ip)
219 return {
220 lat: answer?.lat ?? 0,
221 lon: answer?.lon ?? 0,
222 country: answer?.country ?? '',
223 city: answer?.city ?? '',
224 org: answer?.org || anycast,
225 isAnycast: anycast !== '',
226 anycast,
227 }
228}
229
230export const hasPlace = (p: Place): boolean => p.country !== '' || p.lat !== 0 || p.lon !== 0
231
232/** A country code as a flag emoji falls outside width-1 cells; the code itself is drawn instead. */
233export const countryLabel = (code: string): string => code || '??'
234hooks/globe.ts 232 lines1// The globe: an orthographic view of the land mask with the night side shaded from the real
2// time, great-circle arcs that rise off the surface, and the home dot. It draws into `Pixels`
3// at any size: the half-block Raster uses one pixel per half cell, the Image many more.
4
5import { Pixels, dim, mix } from './canvas'
6import { isLand } from './land'
7
8export type LatLon = { lat: number; lon: number }
9
10export type Arc = {
11 from: LatLon
12 to: LatLon
13 color: number
14 /** 0 to 1: how bright the arc is drawn (fading with age). */
15 strength: number
16 /** 0 to 1: how far from `from` the arc has been drawn (its head travels on a new connection). */
17 progress: number
18 /** 0 to 1: a pulse travelling along the arc while bytes flow; below 0 for none. */
19 pulse: number
20 /** 1 to 3 pixels. */
21 thickness: number
22 /** Drawn as dashes: an inbound connection in observe-all mode, or a blocked attempt. */
23 isDashed: boolean
24 isSelected: boolean
25}
26
27export type Dot = LatLon & { color: number; /** 0 to 1, the ring's expansion; below 0 for none. */ ring: number }
28
29export type Scene = {
30 width: number
31 height: number
32 center: LatLon
33 /** ms since the epoch, for the terminator and the animations. */
34 time: number
35 home: LatLon | null
36 arcs: readonly Arc[]
37 dots: readonly Dot[]
38 /** Quantize the shading to a few levels, for a Raster's palette budget. */
39 isQuantized: boolean
40 /** Draw a faint graticule. */
41 hasGrid: boolean
42}
43
44export const OUTBOUND = 0xffa726
45export const INBOUND = 0x4dd0e1
46export const HOME = 0xff3b30
47export const BLOCKED = 0xff5252
48export const SELECTED = 0xffffff
49
50/** Distinct colors for sessions in observe-all mode. */
51export const SESSION_COLORS = [0xffa726, 0x4dd0e1, 0xba68c8, 0x81c784, 0xf06292, 0xfff176, 0x4fc3f7, 0xffab91, 0xa1887f, 0x90a4ae]
52
53const OCEAN_DAY = 0x1b4f7a
54const OCEAN_NIGHT = 0x07131f
55const LAND_DAY = 0x4c9a52
56const LAND_NIGHT = 0x1a2e1f
57const GRID = 0x2a6a95
58const LIMB = 0x6fa8d6
59
60const RAD = Math.PI / 180
61
62/** Where the sun is overhead at `time`: declination from the day of the year, longitude from the hour. */
63export function subsolarPoint(time: number): LatLon {
64 const d = new Date(time)
65 const start = Date.UTC(d.getUTCFullYear(), 0, 0)
66 const day = (time - start) / 86400000
67 const lat = -23.44 * Math.cos((2 * Math.PI * (day + 10)) / 365.25)
68 const hours = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600
69 // The equation of time, to a few minutes
70 const b = (2 * Math.PI * (day - 81)) / 365
71 const eot = 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b)
72 let lon = -15 * (hours - 12 + eot / 60)
73 lon = ((lon + 540) % 360) - 180
74 return { lat, lon }
75}
76
77/** Unit vector of a place. */
78const toVector = (p: LatLon): [number, number, number] => {
79 const lat = p.lat * RAD
80 const lon = p.lon * RAD
81 return [Math.cos(lat) * Math.cos(lon), Math.cos(lat) * Math.sin(lon), Math.sin(lat)]
82}
83
84/** Projects a place: screen x, y (pixels) and `depth`, the cosine of its angle from the view center. */
85export function project(scene: Scene, p: LatLon, lift = 0): { x: number; y: number; depth: number } {
86 const r = radiusOf(scene)
87 const cx = scene.width / 2
88 const cy = scene.height / 2
89 const lat = p.lat * RAD
90 const lon = p.lon * RAD
91 const lat0 = scene.center.lat * RAD
92 const dLon = lon - scene.center.lon * RAD
93 const x = Math.cos(lat) * Math.sin(dLon)
94 const y = Math.cos(lat0) * Math.sin(lat) - Math.sin(lat0) * Math.cos(lat) * Math.cos(dLon)
95 const depth = Math.sin(lat0) * Math.sin(lat) + Math.cos(lat0) * Math.cos(lat) * Math.cos(dLon)
96 return { x: cx + x * r * (1 + lift), y: cy - y * r * (1 + lift), depth }
97}
98
99export const radiusOf = (scene: Scene) => Math.min(scene.width / 2, scene.height / 2) - 0.5
100
101/** Points along the great circle from `a` to `b`, `n` of them including both ends. */
102export function greatCircle(a: LatLon, b: LatLon, n: number): LatLon[] {
103 const va = toVector(a)
104 const vb = toVector(b)
105 const dot = Math.max(-1, Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]))
106 const omega = Math.acos(dot)
107 const out: LatLon[] = []
108 for (let i = 0; i < n; i++) {
109 const t = n === 1 ? 0 : i / (n - 1)
110 let v: [number, number, number]
111 if (omega < 1e-6) v = va
112 else {
113 const s0 = Math.sin((1 - t) * omega) / Math.sin(omega)
114 const s1 = Math.sin(t * omega) / Math.sin(omega)
115 v = [va[0] * s0 + vb[0] * s1, va[1] * s0 + vb[1] * s1, va[2] * s0 + vb[2] * s1]
116 }
117 const len = Math.hypot(v[0], v[1], v[2]) || 1
118 out.push({ lat: Math.asin(v[2] / len) / RAD, lon: Math.atan2(v[1], v[0]) / RAD })
119 }
120 return out
121}
122
123const quantize = (t: number, levels: number) => Math.round(t * (levels - 1)) / (levels - 1)
124
125/** The sphere: land and sea, lit from the sun's side, with the limb and the grid. */
126function surface(scene: Scene, px: Pixels): void {
127 const r = radiusOf(scene)
128 const cx = scene.width / 2
129 const cy = scene.height / 2
130 const lat0 = scene.center.lat * RAD
131 const lon0 = scene.center.lon * RAD
132 const sun = toVector(subsolarPoint(scene.time))
133 for (let y = 0; y < scene.height; y++) {
134 for (let x = 0; x < scene.width; x++) {
135 const X = (x + 0.5 - cx) / r
136 const Y = (cy - (y + 0.5)) / r
137 const rho2 = X * X + Y * Y
138 if (rho2 > 1) continue
139 const rho = Math.sqrt(rho2)
140 const c = Math.asin(Math.min(1, rho))
141 const cosc = Math.cos(c)
142 const sinc = Math.sin(c)
143 const lat = rho < 1e-9 ? lat0 : Math.asin(cosc * Math.sin(lat0) + (Y * sinc * Math.cos(lat0)) / rho)
144 const lon = rho < 1e-9 ? lon0 : lon0 + Math.atan2(X * sinc, rho * Math.cos(lat0) * cosc - Y * sinc * Math.sin(lat0))
145 const latD = lat / RAD
146 const lonD = ((lon / RAD + 540) % 360) - 180
147 const land = isLand(latD, lonD)
148 const n = toVector({ lat: latD, lon: lonD })
149 const light = n[0] * sun[0] + n[1] * sun[1] + n[2] * sun[2]
150 let day = Math.max(0, Math.min(1, light * 4 + 0.5)) // a soft terminator
151 if (scene.isQuantized) day = quantize(day, 5)
152 let color = land ? mix(LAND_NIGHT, LAND_DAY, day) : mix(OCEAN_NIGHT, OCEAN_DAY, day)
153 if (scene.hasGrid && !land) {
154 const gLat = Math.abs(((latD % 30) + 30) % 30)
155 const gLon = Math.abs(((lonD % 30) + 30) % 30)
156 const tol = 90 / r
157 if (gLat < tol || gLat > 30 - tol || gLon < tol / Math.max(0.2, Math.cos(lat)) || gLon > 30 - tol / Math.max(0.2, Math.cos(lat))) {
158 color = mix(color, GRID, scene.isQuantized ? 0.5 : 0.35)
159 }
160 }
161 if (rho > 0.96) color = mix(color, LIMB, scene.isQuantized ? 0.5 : (rho - 0.96) / 0.04 * 0.6)
162 px.set(x, y, color)
163 }
164 }
165}
166
167function drawArc(scene: Scene, px: Pixels, arc: Arc): void {
168 const distance = Math.acos(
169 Math.max(-1, Math.min(1, toVector(arc.from).reduce((s, v, i) => s + v * toVector(arc.to)[i]!, 0))),
170 )
171 const r = radiusOf(scene)
172 const n = Math.max(8, Math.ceil((distance * r) / 2))
173 const points = greatCircle(arc.from, arc.to, n)
174 const altitude = 0.04 + 0.22 * Math.min(1, distance / Math.PI)
175 const color = arc.isSelected ? SELECTED : dim(arc.color, 0.25 + 0.75 * arc.strength)
176 const drawn = Math.max(1, Math.round(arc.progress * (n - 1)))
177 let prev: { x: number; y: number; depth: number } | null = null
178 for (let i = 0; i <= drawn && i < n; i++) {
179 const t = i / (n - 1)
180 const lift = altitude * Math.sin(Math.PI * t)
181 const at = project(scene, points[i]!, lift)
182 const isVisible = at.depth + lift * 1.2 > 0
183 if (prev && isVisible && (!arc.isDashed || i % 4 < 2)) {
184 px.line(prev.x, prev.y, at.x, at.y, color, arc.thickness)
185 }
186 prev = isVisible ? at : null
187 }
188 if (arc.progress < 1 && prev) {
189 px.set(prev.x, prev.y, SELECTED)
190 px.set(prev.x + 1, prev.y, color)
191 px.set(prev.x - 1, prev.y, color)
192 }
193 if (arc.pulse >= 0) {
194 const i = Math.round(arc.pulse * (n - 1))
195 const lift = altitude * Math.sin(Math.PI * (i / (n - 1)))
196 const at = project(scene, points[i]!, lift)
197 if (at.depth + lift * 1.2 > 0) {
198 px.set(at.x, at.y, SELECTED)
199 px.set(at.x + 1, at.y, mix(color, SELECTED, 0.5))
200 px.set(at.x - 1, at.y, mix(color, SELECTED, 0.5))
201 }
202 }
203}
204
205function drawDot(scene: Scene, px: Pixels, dot: Dot): void {
206 const at = project(scene, dot)
207 if (at.depth < -0.02) return
208 const r = radiusOf(scene)
209 const size = r >= 60 ? 2 : 1
210 for (let dx = -size; dx <= size; dx++) for (let dy = -size; dy <= size; dy++) if (Math.abs(dx) + Math.abs(dy) <= size) px.set(at.x + dx, at.y + dy, dot.color)
211 if (dot.ring >= 0) {
212 const ringR = size + 1 + dot.ring * Math.max(3, r / 12)
213 const color = dim(dot.color, 1 - dot.ring)
214 const steps = Math.max(12, Math.ceil(ringR * 6))
215 for (let i = 0; i < steps; i++) {
216 const a = (i / steps) * 2 * Math.PI
217 px.set(at.x + ringR * Math.cos(a), at.y + ringR * Math.sin(a), color)
218 }
219 }
220}
221
222/** Draws the scene: the sphere, then arcs back to front, then the dots. */
223export function render(scene: Scene): Pixels {
224 const px = new Pixels(scene.width, scene.height)
225 surface(scene, px)
226 const ordered = [...scene.arcs].sort((a, b) => Number(a.isSelected) - Number(b.isSelected) || a.strength - b.strength)
227 for (const arc of ordered) drawArc(scene, px, arc)
228 for (const dot of scene.dots) drawDot(scene, px, dot)
229 if (scene.home) drawDot(scene, px, { ...scene.home, color: HOME, ring: ((scene.time % 2000) / 2000) })
230 return px
231}
232hooks/model.ts 263 lines1// The live trace: what the layers report, folded into events. No `$` here; register.tsx feeds
2// it what the host said and writes what changed to the state.
3
4import { type Place, UNKNOWN_PLACE, anycastOf, formatIp, isLocal, orgOfHost, parseIp, placeOf, type GeoAnswer } from './geo'
5import type { Counter, Socket } from './procs'
6import { isRemote, socketKey } from './procs'
7import type { Intent } from './tools'
8import { type TraceEvent, bounded, correlate, newEvent } from './trace'
9
10export type Owner = { sessionPid: number; session: string; cmd: string }
11
12/** Anthropic's API is anycast; with no database its arc lands at the company's home, and says so. */
13const ANTHROPIC_FALLBACK: Place = { lat: 37.77, lon: -122.42, country: 'US', city: 'San Francisco (anycast, drawn at HQ)', org: 'Anthropic', isAnycast: true, anycast: 'Anthropic' }
14
15export class Live {
16 events: TraceEvent[] = []
17 /** Socket key → event id, for the sockets seen at the last poll. */
18 flows = new Map<string, string>()
19 /** Proxy connection id → event id. */
20 proxyFlows = new Map<number, string>()
21 /** pid → the ports it listens on, to tell inbound connections. */
22 listening = new Map<number, Set<number>>()
23 /** ip → its place, once looked up; UNKNOWN_PLACE when the database had nothing. */
24 geo = new Map<string, Place>()
25 pendingIps = new Set<string>()
26 /** Whether anything changed since the state was last written. */
27 isDirty = false
28 /** How many events were added since the last write (for the status line). */
29 private byId = new Map<string, TraceEvent>()
30
31 load(events: readonly TraceEvent[]): void {
32 this.events = [...events]
33 this.byId = new Map(this.events.map(e => [e.id, e]))
34 for (const e of this.events) if (e.ip && e.place) this.geo.set(e.ip, e.place)
35 }
36
37 get(id: string): TraceEvent | undefined {
38 return this.byId.get(id)
39 }
40
41 private add(e: TraceEvent): TraceEvent {
42 this.events.push(e)
43 this.byId.set(e.id, e)
44 if (this.events.length > 700) {
45 this.events = bounded(this.events)
46 this.byId = new Map(this.events.map(x => [x.id, x]))
47 }
48 this.isDirty = true
49 return e
50 }
51
52 patch(id: string, fields: Partial<TraceEvent>): TraceEvent | undefined {
53 const e = this.byId.get(id)
54 if (!e) return undefined
55 const next = { ...e, ...fields }
56 this.events[this.events.indexOf(e)] = next
57 this.byId.set(id, next)
58 this.isDirty = true
59 return next
60 }
61
62 /** A tool call about to run: one event per host it names, or one for the server or search. */
63 recordTool(intent: Intent, owner: Owner, now: number, status: TraceEvent['status'], note = ''): TraceEvent[] {
64 const base = { t: now, layer: 'tool' as const, tool: intent.tool, summary: intent.summary, status, note, sessionPid: owner.sessionPid, session: owner.session, pid: owner.sessionPid, cmd: owner.cmd }
65 const hosts = intent.kind === 'host' ? intent.hosts : []
66 if (hosts.length === 0) {
67 const host = intent.kind === 'mcp' ? `mcp:${intent.server}` : intent.kind === 'search' ? 'web search' : ''
68 const place = intent.kind === 'none' ? null : { ...UNKNOWN_PLACE, org: intent.kind === 'search' ? 'Anthropic (search)' : '' }
69 return [this.add(newEvent({ ...base, host, place }))]
70 }
71 return hosts.map(host => {
72 const ip = parseIp(host)
73 const known = ip ? this.geo.get(formatIp(ip)) : undefined
74 const org = orgOfHost(host)
75 const place = known ?? (org === 'Anthropic' ? ANTHROPIC_FALLBACK : { ...UNKNOWN_PLACE, org, isAnycast: false })
76 const e = this.add(newEvent({ ...base, host, ip: ip ? formatIp(ip) : '', place }))
77 if (ip && !known && !isLocal(ip)) this.pendingIps.add(formatIp(ip))
78 return e
79 })
80 }
81
82 finishTool(ids: readonly string[], now: number, isError: boolean, bytesIn: number): void {
83 for (const id of ids) {
84 const e = this.byId.get(id)
85 if (!e || e.status === 'blocked') continue
86 this.patch(id, { status: isError ? 'failed' : 'done', last: now, bytesIn: Math.max(e.bytesIn, bytesIn) })
87 }
88 }
89
90 /** The sockets of one poll: new ones open events, missing ones close theirs, counters update bytes. */
91 observeSockets(sockets: readonly Socket[], ownerOf: (pid: number) => Owner | undefined, now: number, counters: readonly Counter[] = []): void {
92 this.listening.clear()
93 for (const s of sockets) {
94 if (s.state === 'LISTEN' || (s.proto === 'udp' && s.remote === '')) {
95 const ports = this.listening.get(s.pid) ?? new Set<number>()
96 ports.add(s.localPort)
97 this.listening.set(s.pid, ports)
98 }
99 }
100 const byCounter = new Map(counters.map(c => [`${c.local}:${c.localPort}>${c.remote}:${c.remotePort}`, c]))
101 const seen = new Set<string>()
102 for (const s of sockets) {
103 if (!isRemote(s)) continue
104 const ip = parseIp(s.remote)
105 if (!ip || isLocal(ip)) continue
106 const owner = ownerOf(s.pid)
107 if (!owner) continue
108 const key = socketKey(s)
109 seen.add(key)
110 const ipText = formatIp(ip)
111 const counter = byCounter.get(`${s.local}:${s.localPort}>${s.remote}:${s.remotePort}`)
112 const bytesIn = counter ? counter.bytesIn : s.bytesIn
113 const bytesOut = counter ? counter.bytesOut : s.bytesOut
114 const existingId = this.flows.get(key)
115 const existing = existingId ? this.byId.get(existingId) : undefined
116 if (existing) {
117 if ((bytesIn >= 0 && bytesIn !== existing.bytesIn) || (bytesOut >= 0 && bytesOut !== existing.bytesOut)) {
118 this.patch(existing.id, { bytesIn: Math.max(bytesIn, existing.bytesIn), bytesOut: Math.max(bytesOut, existing.bytesOut), last: now })
119 } else if (s.state === 'ESTABLISHED' && existing.status === 'open') {
120 // No counters here: a long-lived connection still counts as alive.
121 }
122 continue
123 }
124 const isInbound = this.listening.get(s.pid)?.has(s.localPort) === true
125 const place = this.geo.get(ipText) ?? null
126 const e = this.add(
127 newEvent({
128 t: now,
129 layer: 'socket',
130 sessionPid: owner.sessionPid,
131 session: owner.session,
132 pid: s.pid,
133 cmd: owner.cmd,
134 ip: ipText,
135 port: s.remotePort,
136 direction: isInbound ? 'in' : 'out',
137 bytesIn: Math.max(0, bytesIn),
138 bytesOut: Math.max(0, bytesOut),
139 summary: `${owner.cmd} ${s.proto} ${isInbound ? '←' : '→'} ${ipText}:${s.remotePort}`,
140 place: place ?? (anycastOf(ip) ? { ...UNKNOWN_PLACE, isAnycast: true, anycast: anycastOf(ip), org: anycastOf(ip) } : null),
141 }),
142 )
143 this.flows.set(key, e.id)
144 if (!place) this.pendingIps.add(ipText)
145 const match = correlate(this.events, e)
146 if (match) {
147 this.patch(e.id, { host: match.host, tool: match.tool.tool, linked: match.tool.id, place: place ?? e.place ?? match.tool.place })
148 const toolPlace = place ?? match.tool.place
149 this.patch(match.tool.id, { linked: e.id, ip: ipText, port: s.remotePort, place: toolPlace, last: now })
150 } else if (anycastOf(ip) === 'Anthropic') {
151 this.patch(e.id, { host: 'api.anthropic.com', place: place ?? ANTHROPIC_FALLBACK })
152 }
153 }
154 for (const [key, id] of [...this.flows]) {
155 if (seen.has(key)) continue
156 this.flows.delete(key)
157 const e = this.byId.get(id)
158 if (e && e.status === 'open') {
159 this.patch(id, { status: 'closed', last: now })
160 if (e.linked) {
161 const tool = this.byId.get(e.linked)
162 if (tool && tool.layer === 'tool' && tool.status === 'open') this.patch(tool.id, { bytesIn: Math.max(tool.bytesIn, e.bytesIn), bytesOut: Math.max(tool.bytesOut, e.bytesOut) })
163 }
164 }
165 }
166 }
167
168 /** The proxy helper's log lines since the last read. */
169 observeProxy(lines: readonly string[], owner: Owner, now: number): void {
170 for (const line of lines) {
171 let entry: any
172 try {
173 entry = JSON.parse(line)
174 } catch {
175 continue
176 }
177 if (entry.type === 'open') {
178 const host = String(entry.host ?? '')
179 const ipParsed = parseIp(host)
180 const ipText = ipParsed ? formatIp(ipParsed) : ''
181 const org = orgOfHost(host)
182 const e = this.add(
183 newEvent({
184 t: Number(entry.t) || now,
185 layer: 'proxy',
186 sessionPid: owner.sessionPid,
187 session: owner.session,
188 host: ipParsed ? '' : host,
189 ip: ipText,
190 port: Number(entry.port) || 0,
191 summary: `${entry.method ?? 'CONNECT'} ${host}:${entry.port ?? ''}`,
192 place: org === 'Anthropic' ? ANTHROPIC_FALLBACK : org ? { ...UNKNOWN_PLACE, org } : null,
193 }),
194 )
195 this.proxyFlows.set(Number(entry.id), e.id)
196 if (ipText && !this.geo.has(ipText)) this.pendingIps.add(ipText)
197 const match = correlate(this.events, e)
198 if (match) this.patch(match.tool.id, { linked: e.id, last: now })
199 } else if (entry.type === 'close') {
200 const id = this.proxyFlows.get(Number(entry.id))
201 if (!id) continue
202 this.proxyFlows.delete(Number(entry.id))
203 this.patch(id, { status: entry.error ? 'failed' : 'closed', last: Number(entry.t) || now, bytesIn: Number(entry.bytesIn) || 0, bytesOut: Number(entry.bytesOut) || 0, note: entry.error ? String(entry.error) : '' })
204 } else if (entry.type === 'ip') {
205 const id = this.proxyFlows.get(Number(entry.id))
206 const ipParsed = parseIp(String(entry.ip ?? ''))
207 if (!id || !ipParsed) continue
208 const ipText = formatIp(ipParsed)
209 this.patch(id, { ip: ipText, place: this.geo.get(ipText) ?? this.byId.get(id)?.place ?? null })
210 if (!this.geo.has(ipText)) this.pendingIps.add(ipText)
211 }
212 }
213 }
214
215 /** The Anthropic stream, seen from the turn itself when no socket source can see it. */
216 recordStream(owner: Owner, host: string, now: number): TraceEvent {
217 const open = this.events.findLast(e => e.layer === 'stream' && e.status === 'open' && e.sessionPid === owner.sessionPid)
218 if (open) {
219 this.patch(open.id, { last: now })
220 return open
221 }
222 return this.add(newEvent({ t: now, layer: 'stream', host, summary: 'model request', sessionPid: owner.sessionPid, session: owner.session, pid: owner.sessionPid, cmd: owner.cmd, place: ANTHROPIC_FALLBACK }))
223 }
224
225 closeStream(owner: Owner, now: number, bytesIn: number): void {
226 const open = this.events.findLast(e => e.layer === 'stream' && e.status === 'open' && e.sessionPid === owner.sessionPid)
227 if (open) this.patch(open.id, { status: 'done', last: now, bytesIn: open.bytesIn + bytesIn })
228 }
229
230 /** Takes the addresses waiting for a lookup, at most `n`. */
231 takePending(n = 40): string[] {
232 const out = [...this.pendingIps].slice(0, n)
233 for (const ip of out) this.pendingIps.delete(ip)
234 return out
235 }
236
237 /** Applies the geo helper's answers to every event at those addresses. */
238 applyGeo(answers: readonly GeoAnswer[]): void {
239 for (const a of answers) {
240 const ip = parseIp(a.ip)
241 if (!ip) continue
242 const place = placeOf(a, ip)
243 this.geo.set(formatIp(ip), place)
244 for (const e of this.events) {
245 if (e.ip !== formatIp(ip)) continue
246 const org = place.org || e.place?.org || orgOfHost(e.host)
247 const isAnthropic = /anthropic/i.test(org) || /anthropic/i.test(e.host)
248 const merged: Place = {
249 ...place,
250 org,
251 isAnycast: place.isAnycast || isAnthropic,
252 anycast: place.anycast || (isAnthropic ? 'Anthropic' : ''),
253 lat: place.lat || e.place?.lat || 0,
254 lon: place.lon || e.place?.lon || 0,
255 city: place.city || e.place?.city || '',
256 country: place.country || e.place?.country || '',
257 }
258 this.patch(e.id, { place: merged })
259 }
260 }
261 }
262}
263hooks/pane.tsx 306 lines1// The pane: the globe, the badge, the legend, the event log, the replay scrubber, and the
2// capability line. Pure drawing from a model; the actions call back into register.tsx.
3
4import type { Elements, RenderSurface } from 'claude-code'
5
6/** The terminal's element table; the other surfaces' tables share Box, Text and Button, which is all they get here. */
7export type Table = Elements['terminal']
8
9import { hex } from './canvas'
10import { HOME, INBOUND, OUTBOUND, BLOCKED } from './globe'
11import type { Mode, Rule } from './rules'
12import { describeRule } from './rules'
13import type { OrbitAsk, OrbitCapabilities, OrbitHome, OrbitReplay, OrbitSession } from '../types'
14import { type TraceEvent, type Summary, formatBytes, sessionColor } from './trace'
15
16export type GlobeBox = { columns: number; rows: number; renderer: 'image' | 'cells'; cells: string; png: string }
17
18export type PaneModel = {
19 surface: RenderSurface
20 bodyColumns: number
21 viewportRows: number
22 events: readonly TraceEvent[]
23 summary: Summary
24 scope: 'session' | 'all'
25 mode: Mode
26 rules: readonly Rule[]
27 home: OrbitHome
28 selectedId: string
29 capabilities: OrbitCapabilities
30 replay: OrbitReplay | null
31 sessions: readonly OrbitSession[]
32 globe: GlobeBox | null
33 isGenerating: boolean
34 spin: number | null
35}
36
37export type Actions = {
38 setScope: (scope: 'session' | 'all') => void
39 select: (id: string) => void
40 spin: (delta: number | null) => void
41 replayToggle: () => void
42 replaySeek: (direction: -1 | 1) => void
43 replaySpeed: () => void
44 replayClose: () => void
45 downloadGeo: (level: 'city' | 'country') => void
46 exportTrace: () => void
47 block: (subject: string) => void
48 allow: (subject: string) => void
49 unrule: (rule: Rule) => void
50 clear: () => void
51}
52
53const DIM = 0x8a8f98
54
55export const folder = (cwd: string) => cwd.slice(cwd.lastIndexOf('/') + 1) || cwd || '?'
56
57const clock = (t: number) => {
58 const d = new Date(t)
59 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
60}
61
62const layerGlyph = (e: TraceEvent) => (e.status === 'blocked' ? '✕' : e.layer === 'tool' ? '⚙' : e.layer === 'socket' ? '⇄' : e.layer === 'proxy' ? '⇶' : '≋')
63
64const pad = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s.padEnd(n))
65
66/** One line of the log: time, layer, what, where, who, bytes. */
67export function eventLine(e: TraceEvent, width: number, isAll: boolean): string {
68 const what = e.tool ? e.tool.replace(/^mcp__(.+?)__.*$/, 'mcp:$1') : e.cmd || e.layer
69 const where = e.host || e.ip || e.summary
70 const org = e.place?.org ?? ''
71 const country = e.place?.country ?? ''
72 const anycast = e.place?.isAnycast ? '~' : ''
73 const direction = e.direction === 'in' ? '←' : '→'
74 const bytes = e.bytesIn > 0 || e.bytesOut > 0 ? `↓${formatBytes(e.bytesIn)} ↑${formatBytes(e.bytesOut)}` : e.status === 'open' ? '…' : ''
75 const session = isAll ? pad(e.session, 14) + ' ' : ''
76 const head = `${clock(e.t)} ${layerGlyph(e)} ${session}${pad(what, 10)} ${direction} `
77 const tail = ` ${pad(anycast + country, 3)} ${bytes}`
78 const room = Math.max(8, width - head.length - tail.length - (org ? Math.min(org.length, 18) + 1 : 0))
79 return `${head}${pad(where, room)}${org ? ' ' + pad(org, Math.min(org.length, 18)) : ''}${tail}`
80}
81
82export function detailLines(e: TraceEvent): string[] {
83 const lines = [`${layerGlyph(e)} ${e.summary || e.host || e.ip}`]
84 const where = [e.host, e.ip && e.ip !== e.host ? `${e.ip}${e.port ? ':' + e.port : ''}` : ''].filter(Boolean).join(' = ')
85 if (where) lines.push(where)
86 if (e.place) {
87 const place = [e.place.city, e.place.country].filter(Boolean).join(', ')
88 const org = e.place.org ? ` · ${e.place.org}` : ''
89 const note = e.place.isAnycast ? ` · anycast/CDN (${e.place.anycast || 'edge'}): place unreliable` : ''
90 if (place || org || note) lines.push(`${place || 'place unknown'}${org}${note}`)
91 }
92 lines.push(`${e.direction === 'in' ? 'inbound' : 'outbound'} · ${e.status} · ↓${formatBytes(e.bytesIn)} ↑${formatBytes(e.bytesOut)} · pid ${e.pid}${e.cmd ? ' ' + e.cmd : ''} · ${e.session || 'this session'}`)
93 if (e.note) lines.push(e.note)
94 if (e.linked) lines.push(`matched to ${e.layer === 'tool' ? 'a socket' : 'a tool call'} (${e.linked})`)
95 return lines
96}
97
98function capabilityLine(c: OrbitCapabilities): string {
99 const parts = [
100 'tools ✓',
101 c.sockets ? `sockets ✓ ${c.sockets}` : 'sockets ✗',
102 c.bytes ? `bytes ✓ ${c.bytes}` : 'bytes ✗',
103 c.geo === 'ready' ? 'geo ✓' : c.geo === 'downloading' ? 'geo ⇣' : `geo ✗`,
104 c.proxy === 'on' ? `proxy ✓ :${c.proxyPort}` : c.proxy === 'starting' ? 'proxy …' : c.proxy === 'error' ? 'proxy ✗' : 'proxy off',
105 ]
106 return parts.join(' · ')
107}
108
109function badge(m: PaneModel): string {
110 const s = m.summary
111 const who = m.scope === 'all' ? `${s.sessions} session${s.sessions === 1 ? '' : 's'} talked to` : 'this session talked to'
112 const n = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : (word.endsWith('y') ? 'ies' : 's').replace('yies', 'ies')}`
113 const countries = `${s.countries.length} ${s.countries.length === 1 ? 'country' : 'countries'}`
114 return `${who} ${countries} · ${n(s.orgs.length, 'org')} · ${n(s.hosts.length, 'host')} · ↓${formatBytes(s.bytesIn)} ↑${formatBytes(s.bytesOut)}${s.blocked ? ` · ${s.blocked} blocked` : ''}`
115}
116
117export function drawPane(ui: Table, m: PaneModel, a: Actions) {
118 const { Box, Text, Button } = ui
119 const width = Math.max(30, m.bodyColumns)
120 const isAll = m.scope === 'all'
121 const sessionPids = [...new Set(m.events.map(ev => ev.sessionPid))].sort((x, y) => x - y)
122 const selected = m.events.find(ev => ev.id === m.selectedId)
123
124 const header = (
125 <Box flexDirection="row" gap={1} flexWrap="wrap">
126 <Button key="scope-session" plain dimColor={isAll} onPress={() => a.setScope('session')}>{isAll ? 'session' : '● session'}</Button>
127 <Button key="scope-all" plain dimColor={!isAll} onPress={() => a.setScope('all')}>{isAll ? '● all sessions' : 'all sessions'}</Button>
128 <Text dimColor>│</Text>
129 <Button key="spin-left" plain onPress={() => a.spin(-20)}>◀</Button>
130 <Button key="spin-home" plain dimColor={m.spin === null} onPress={() => a.spin(null)}>⌂</Button>
131 <Button key="spin-right" plain onPress={() => a.spin(20)}>▶</Button>
132 <Text dimColor>│</Text>
133 <Button key="export" plain onPress={a.exportTrace}>export</Button>
134 <Button key="clear" plain dimColor onPress={a.clear}>clear</Button>
135 <Text dimColor>│ {m.mode === 'off' ? 'watching' : `enforcing: ${m.mode}`}</Text>
136 </Box>
137 )
138
139 const legend = isAll ? (
140 <Box flexDirection="column">
141 {sessionPids.map(pid => {
142 const s = m.sessions.find(x => x.pid === pid)
143 const label = m.events.find(ev => ev.sessionPid === pid)?.session || (s ? `${folder(s.cwd)} ${s.tty}` : `pid ${pid}`)
144 return <Text color={hex(sessionColor(pid, sessionPids))} wrap="truncate">{`━ ${label}${pid === m.capabilities.self ? ' (this one)' : ''}`}</Text>
145 })}
146 <Text dimColor>dashed: inbound</Text>
147 </Box>
148 ) : (
149 <Box flexDirection="row" gap={2}>
150 <Text color={hex(OUTBOUND)}>━ out</Text>
151 <Text color={hex(INBOUND)}>━ in</Text>
152 <Text color={hex(BLOCKED)}>╌ blocked</Text>
153 <Text color={hex(HOME)}>● you</Text>
154 </Box>
155 )
156
157 const homeLine =
158 m.home.source === 'none' ? (
159 <Text color="#ffa726" wrap="wrap">No home yet: set it in /config → Where you are, or /orbit locate (one public-IP lookup).</Text>
160 ) : (
161 <Text dimColor wrap="truncate">{`you: ${m.home.label || `${m.home.lat.toFixed(2)}, ${m.home.lon.toFixed(2)}`}${m.home.source === 'lookup' ? ' (from your public IP)' : ''}`}</Text>
162 )
163
164 const geoLine =
165 m.capabilities.geo === 'ready' ? null : m.capabilities.geo === 'downloading' ? (
166 <Text color="#4dd0e1" wrap="wrap">{`Downloading the geo database… ${m.capabilities.geoNote}`}</Text>
167 ) : m.capabilities.geo === 'no-node' ? (
168 <Text color="#ffa726" wrap="wrap">Places need Node.js for the geo helper: install node, or set its path in /config.</Text>
169 ) : (
170 <Box flexDirection="column">
171 <Text color="#ffa726" wrap="wrap">{`No geo database: connections cannot be placed on the globe yet.${m.capabilities.geoNote ? ' ' + m.capabilities.geoNote : ''}`}</Text>
172 <Box flexDirection="row" gap={1}>
173 <Button key="geo-city" variant="primary" onPress={() => a.downloadGeo('city')}>Download DB-IP city (~130 MB)</Button>
174 <Button key="geo-country" onPress={() => a.downloadGeo('country')}>country only (~10 MB)</Button>
175 </Box>
176 </Box>
177 )
178
179 const replay = m.replay ? (
180 <Box flexDirection="column">
181 <Text color="#ba68c8" wrap="truncate">{`replay ${m.replay.file.slice(m.replay.file.lastIndexOf('/') + 1)} · ${m.replay.count} events · ${clock(m.replay.position)}`}</Text>
182 <Box flexDirection="row" gap={1}>
183 <Button key="rp-back" plain onPress={() => a.replaySeek(-1)}>⏮</Button>
184 <Button key="rp-toggle" plain onPress={a.replayToggle}>{m.replay.isPlaying ? '⏸' : '▶'}</Button>
185 <Button key="rp-fwd" plain onPress={() => a.replaySeek(1)}>⏭</Button>
186 <Button key="rp-speed" plain onPress={a.replaySpeed}>{`${m.replay.speed}×`}</Button>
187 <Text color="#ba68c8">{scrub(m.replay, Math.max(8, Math.min(width - 24, 60)))}</Text>
188 <Button key="rp-close" plain dimColor onPress={a.replayClose}>close</Button>
189 </Box>
190 </Box>
191 ) : null
192
193 const side = (
194 <Box flexDirection="column" paddingX={1} flexGrow={1}>
195 <Text bold wrap="wrap">{badge(m)}</Text>
196 {m.isGenerating && <Text color={hex(OUTBOUND)}>≋ Anthropic stream breathing…</Text>}
197 {legend}
198 {homeLine}
199 {geoLine}
200 <Text dimColor wrap="wrap">{capabilityLine(m.capabilities)}</Text>
201 </Box>
202 )
203
204 let globe: any = null
205 if (m.surface === 'terminal' && m.globe) {
206 const { Raster, Image } = ui
207 globe =
208 m.globe.renderer === 'image' ? (
209 <Image key="globe" source={{ png: m.globe.png }} columns={m.globe.columns} rows={m.globe.rows} alt="the globe" />
210 ) : (
211 <Raster key="globe" columns={m.globe.columns} rows={m.globe.rows} cells={m.globe.cells} />
212 )
213 } else if (m.surface !== 'terminal') {
214 globe = <Text dimColor wrap="wrap">The globe draws in the terminal; here is the trace.</Text>
215 }
216
217 const sideBySide = m.globe !== null && width >= m.globe.columns + 44
218 const globeRows = m.globe ? m.globe.rows : 1
219 const used = 2 + (sideBySide ? Math.max(globeRows, 8) : globeRows + 8) + (m.replay ? 2 : 0) + (selected ? 5 : 0) + 2
220 const room = Math.max(3, m.viewportRows - used)
221 const listed = [...m.events].sort((x, y) => y.t - x.t).slice(0, room)
222
223 const log = (
224 <Box flexDirection="column">
225 {listed.length === 0 && <Text dimColor>Nothing yet: tool calls, sockets of this session's processes and proxied connections will appear here.</Text>}
226 {listed.map(ev => (
227 <Button key={`ev:${ev.id}`} plain dimColor={ev.id !== m.selectedId && ev.status !== 'open'} onPress={() => a.select(ev.id)}>
228 {eventLine(ev, width - 1, isAll)}
229 </Button>
230 ))}
231 </Box>
232 )
233
234 const detail = selected ? (
235 <Box flexDirection="column" paddingX={1} borderStyle="round" borderDimColor>
236 {detailLines(selected).map(line => (
237 <Text wrap="truncate">{line}</Text>
238 ))}
239 <Box flexDirection="row" gap={1}>
240 {(selected.host || selected.ip) && selected.status !== 'blocked' && (
241 <Button key="detail-block" plain onPress={() => a.block(selected.host && !selected.host.startsWith('mcp:') ? selected.host : selected.ip)}>block this host</Button>
242 )}
243 {selected.host.startsWith('mcp:') && <Button key="detail-block-mcp" plain onPress={() => a.block(selected.host)}>block this server</Button>}
244 {selected.status === 'blocked' && <Button key="detail-allow" plain onPress={() => a.allow(selected.note.replace(/^.*?(host|mcp|tool):/, '$1:'))}>allow it</Button>}
245 <Button key="detail-close" plain dimColor onPress={() => a.select('')}>close</Button>
246 </Box>
247 </Box>
248 ) : null
249
250 const rules =
251 m.rules.length > 0 ? (
252 <Box flexDirection="row" gap={1} flexWrap="wrap">
253 <Text dimColor>rules:</Text>
254 {m.rules.slice(0, 8).map(r => (
255 <Button key={`rule:${r.kind}:${r.pattern}`} plain dimColor onPress={() => a.unrule(r)}>{`${describeRule(r)} ✕`}</Button>
256 ))}
257 {m.rules.length > 8 && <Text dimColor>{`+${m.rules.length - 8} more (/orbit rules)`}</Text>}
258 </Box>
259 ) : null
260
261 return (
262 <Box flexDirection="column">
263 {header}
264 {sideBySide ? (
265 <Box flexDirection="row">
266 {globe}
267 {side}
268 </Box>
269 ) : (
270 <Box flexDirection="column">
271 {globe}
272 {side}
273 </Box>
274 )}
275 {replay}
276 {detail}
277 {rules}
278 {log}
279 </Box>
280 )
281}
282
283function scrub(r: OrbitReplay, width: number): string {
284 const at = Math.round(((r.position - r.start) / Math.max(1, r.end - r.start)) * (width - 1))
285 return Array.from({ length: width }, (_, i) => (i < at ? '━' : i === at ? '●' : '─')).join('')
286}
287
288export function drawAsk(ui: Table, ask: OrbitAsk, answer: (verdict: 'allow' | 'deny', isAlways: boolean) => void) {
289 const { Box, Text, Button } = ui
290 return (
291 <Box flexDirection="column" paddingX={1}>
292 <Text bold color="#ffa726">{`${ask.tool} wants to reach ${ask.kind === 'mcp' ? 'MCP server' : ''} ${ask.subject}`}</Text>
293 <Text dimColor wrap="truncate">{ask.summary}</Text>
294 <Text dimColor>No answer in 8 seconds denies it.</Text>
295 <Box flexDirection="row" gap={1} flexWrap="wrap">
296 <Button key="ask-allow" hotkey="1" variant="primary" autoFocus onPress={() => answer('allow', false)}>Allow once</Button>
297 <Button key="ask-allow-always" hotkey="2" onPress={() => answer('allow', true)}>Always allow</Button>
298 <Button key="ask-deny" hotkey="3" onPress={() => answer('deny', false)}>Deny</Button>
299 <Button key="ask-deny-always" hotkey="4" onPress={() => answer('deny', true)}>Always deny</Button>
300 </Box>
301 </Box>
302 )
303}
304
305export const DIM_COLOR = hex(DIM)
306hooks/png.ts 199 lines1// A PNG encoder with its own deflate, since the hooks runtime has no zlib: LZ77 over a hash chain
2// with the fixed Huffman code (RFC 1951 §3.2.6). A globe frame is mostly flat color and long
3// repeats, so it compresses to a few percent of the raw pixels.
4
5const CRC_TABLE = new Uint32Array(256).map((_, n) => {
6 let c = n
7 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
8 return c >>> 0
9})
10
11export function crc32(bytes: Uint8Array, start = 0, end = bytes.length): number {
12 let c = 0xffffffff
13 for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8)
14 return (c ^ 0xffffffff) >>> 0
15}
16
17export function adler32(bytes: Uint8Array): number {
18 let a = 1
19 let b = 0
20 for (let i = 0; i < bytes.length; i++) {
21 a = (a + bytes[i]!) % 65521
22 b = (b + a) % 65521
23 }
24 return ((b << 16) | a) >>> 0
25}
26
27/** Writes bits least-significant first, as deflate reads them. */
28class BitWriter {
29 private out = new Uint8Array(1 << 16)
30 private length = 0
31 private acc = 0
32 private bits = 0
33
34 write(value: number, count: number): void {
35 this.acc |= value << this.bits
36 this.bits += count
37 while (this.bits >= 8) {
38 this.push(this.acc & 0xff)
39 this.acc >>>= 8
40 this.bits -= 8
41 }
42 }
43
44 /** A Huffman code is written most-significant bit first. */
45 writeCode(code: number, length: number): void {
46 let reversed = 0
47 for (let i = 0; i < length; i++) reversed = (reversed << 1) | ((code >> i) & 1)
48 this.write(reversed, length)
49 }
50
51 private push(byte: number): void {
52 if (this.length === this.out.length) {
53 const bigger = new Uint8Array(this.out.length * 2)
54 bigger.set(this.out)
55 this.out = bigger
56 }
57 this.out[this.length++] = byte
58 }
59
60 finish(): Uint8Array {
61 if (this.bits > 0) this.push(this.acc & 0xff)
62 return this.out.slice(0, this.length)
63 }
64}
65
66const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
67const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
68const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
69const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
70
71function writeLiteral(w: BitWriter, byte: number): void {
72 if (byte < 144) w.writeCode(0x30 + byte, 8)
73 else w.writeCode(0x190 + (byte - 144), 9)
74}
75
76function writeSymbol(w: BitWriter, symbol: number): void {
77 // 256..279 are 7-bit codes 0000000..0010111; 280..287 are 8-bit 11000000..
78 if (symbol < 280) w.writeCode(symbol - 256, 7)
79 else w.writeCode(0xc0 + (symbol - 280), 8)
80}
81
82function writeMatch(w: BitWriter, length: number, distance: number): void {
83 let li = LENGTH_BASE.length - 1
84 while (LENGTH_BASE[li]! > length) li--
85 writeSymbol(w, 257 + li)
86 if (LENGTH_EXTRA[li]! > 0) w.write(length - LENGTH_BASE[li]!, LENGTH_EXTRA[li]!)
87 let di = DIST_BASE.length - 1
88 while (DIST_BASE[di]! > distance) di--
89 w.writeCode(di, 5)
90 if (DIST_EXTRA[di]! > 0) w.write(distance - DIST_BASE[di]!, DIST_EXTRA[di]!)
91}
92
93const WINDOW = 32768
94const HASH_BITS = 15
95const MAX_CHAIN = 16
96
97/** Deflate: one fixed-Huffman block over an LZ77 parse of `data`. */
98export function deflate(data: Uint8Array): Uint8Array {
99 const w = new BitWriter()
100 w.write(1, 1) // final block
101 w.write(1, 2) // fixed Huffman
102 const head = new Int32Array(1 << HASH_BITS).fill(-1)
103 const prev = new Int32Array(WINDOW)
104 const hashAt = (i: number) => ((data[i]! << 10) ^ (data[i + 1]! << 5) ^ data[i + 2]!) & ((1 << HASH_BITS) - 1)
105 let i = 0
106 while (i < data.length) {
107 let bestLength = 0
108 let bestDistance = 0
109 if (i + 2 < data.length) {
110 const h = hashAt(i)
111 let candidate = head[h]!
112 let chain = 0
113 while (candidate >= 0 && i - candidate <= WINDOW && chain < MAX_CHAIN) {
114 const limit = Math.min(258, data.length - i)
115 let length = 0
116 while (length < limit && data[candidate + length] === data[i + length]) length++
117 if (length > bestLength) {
118 bestLength = length
119 bestDistance = i - candidate
120 if (length === limit) break
121 }
122 candidate = prev[candidate % WINDOW]!
123 chain++
124 }
125 prev[i % WINDOW] = head[h]!
126 head[h] = i
127 }
128 if (bestLength >= 3) {
129 writeMatch(w, bestLength, bestDistance)
130 for (let k = 1; k < bestLength; k++) {
131 const j = i + k
132 if (j + 2 < data.length) {
133 const h = hashAt(j)
134 prev[j % WINDOW] = head[h]!
135 head[h] = j
136 }
137 }
138 i += bestLength
139 } else {
140 writeLiteral(w, data[i]!)
141 i++
142 }
143 }
144 writeSymbol(w, 256)
145 return w.finish()
146}
147
148function zlib(data: Uint8Array): Uint8Array {
149 const body = deflate(data)
150 const out = new Uint8Array(body.length + 6)
151 out[0] = 0x78
152 out[1] = 0x01
153 out.set(body, 2)
154 const sum = adler32(data)
155 out[out.length - 4] = sum >>> 24
156 out[out.length - 3] = (sum >>> 16) & 0xff
157 out[out.length - 2] = (sum >>> 8) & 0xff
158 out[out.length - 1] = sum & 0xff
159 return out
160}
161
162function chunk(type: string, body: Uint8Array): Uint8Array {
163 const out = new Uint8Array(body.length + 12)
164 const view = new DataView(out.buffer)
165 view.setUint32(0, body.length)
166 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
167 out.set(body, 8)
168 view.setUint32(body.length + 8, crc32(out, 4, body.length + 8))
169 return out
170}
171
172/** A PNG of `width × height` RGBA pixels. */
173export function encodePng(rgba: Uint8Array, width: number, height: number): Uint8Array {
174 const raw = new Uint8Array((width * 4 + 1) * height)
175 for (let y = 0; y < height; y++) {
176 raw[y * (width * 4 + 1)] = 0 // filter: none
177 raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1)
178 }
179 const header = new Uint8Array(13)
180 const hv = new DataView(header.buffer)
181 hv.setUint32(0, width)
182 hv.setUint32(4, height)
183 header[8] = 8 // bit depth
184 header[9] = 6 // RGBA
185 const parts = [
186 Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
187 chunk('IHDR', header),
188 chunk('IDAT', zlib(raw)),
189 chunk('IEND', new Uint8Array(0)),
190 ]
191 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
192 let at = 0
193 for (const p of parts) {
194 out.set(p, at)
195 at += p.length
196 }
197 return out
198}
199hooks/procs.ts 268 lines1// The process layer: the Claude session's process tree and the sockets it holds, read from `ps`
2// and `lsof` on macOS, `ss` (or /proc) on Linux. Pure parsers and argv builders; register.tsx runs
3// them. Everything here is read-only and needs no privileges for the user's own processes.
4
5export const PS_ARGV = ['ps', '-x', '-o', 'pid=,ppid=,tty=,args='] as const
6
7export type Proc = { pid: number; ppid: number; tty: string; cmd: string; args: string }
8
9const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
10
11export function procsIn(ps: string): Proc[] {
12 return ps
13 .split('\n')
14 .map(line => {
15 const [pid = '', ppid = '', tty = '', argv0 = '', ...rest] = line.trim().split(/\s+/)
16 return { pid: Number(pid), ppid: Number(ppid), tty, cmd: base(argv0), args: rest.join(' ') }
17 })
18 .filter(p => p.pid > 0 && p.cmd !== '')
19}
20
21/** `claude` with a terminal, and not one of the daemon's helpers: how redline counts sessions. */
22export const isSession = (p: Proc) =>
23 p.cmd === 'claude' && p.tty !== '??' && p.tty !== '?' && p.tty !== '' && !p.args.startsWith('daemon') && !p.args.startsWith('bg-')
24
25export type Session = { pid: number; tty: string; cwd: string; isWorking: boolean }
26
27export function sessionsIn(ps: string): Session[] {
28 const procs = procsIn(ps)
29 const caffeinated = new Set(procs.filter(p => p.cmd === 'caffeinate').map(p => p.ppid))
30 return procs
31 .filter(isSession)
32 .map(p => ({ pid: p.pid, tty: p.tty, cwd: '', isWorking: caffeinated.has(p.pid) }))
33 .sort((a, b) => a.tty.localeCompare(b.tty) || a.pid - b.pid)
34}
35
36/** The session `pid` runs under: itself or its nearest session ancestor; 0 for none. */
37export function sessionOf(ps: string, pid: number): number {
38 const procs = new Map(procsIn(ps).map(p => [p.pid, p]))
39 for (let p = procs.get(pid), hops = 0; p && hops < 64; p = procs.get(p.ppid), hops++) {
40 if (isSession(p)) return p.pid
41 }
42 return 0
43}
44
45/** `root` and every process beneath it, with each one's command name. */
46export function treeOf(ps: string, root: number): Map<number, string> {
47 const procs = procsIn(ps)
48 const children = new Map<number, Proc[]>()
49 for (const p of procs) children.set(p.ppid, [...(children.get(p.ppid) ?? []), p])
50 const out = new Map<number, string>()
51 const self = procs.find(p => p.pid === root)
52 if (!self) return out
53 const queue = [self]
54 while (queue.length) {
55 const p = queue.shift()!
56 if (out.has(p.pid)) continue
57 out.set(p.pid, p.cmd)
58 for (const c of children.get(p.pid) ?? []) queue.push(c)
59 }
60 return out
61}
62
63/** Working directories of `pids`, in lsof's field format: `p<pid>`, then `n<path>`. */
64export const lsofCwdArgv = (pids: readonly number[]) => ['lsof', '-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')]
65
66export function cwdsIn(lsof: string): Map<number, string> {
67 const cwds = new Map<number, string>()
68 let pid = 0
69 for (const line of lsof.split('\n')) {
70 if (line.startsWith('p')) pid = Number(line.slice(1))
71 else if (line.startsWith('n') && pid) cwds.set(pid, line.slice(1))
72 }
73 return cwds
74}
75
76/** One socket a process holds. Bytes are -1 where the source does not count them. */
77export type Socket = {
78 pid: number
79 proto: 'tcp' | 'udp'
80 local: string
81 localPort: number
82 remote: string
83 remotePort: number
84 state: string // ESTABLISHED, LISTEN, SYN_SENT, ...; '' for UDP
85 bytesIn: number
86 bytesOut: number
87}
88
89export const socketKey = (s: Socket) => `${s.pid} ${s.proto} ${s.local}:${s.localPort}>${s.remote}:${s.remotePort}`
90
91/** A host as the trace spells it: a mapped IPv4 as dotted, and the wildcards (`*`, `0.0.0.0`, `::`) as ''. */
92function cleanHost(host: string): string {
93 if (host === '*' || host === '0.0.0.0' || host === '::' || host === '') return ''
94 const m = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(host)
95 return m ? m[1]! : host
96}
97
98/** Splits `host:port`, with `[v6]:port` and lsof's `*:port`. */
99export function splitAddress(text: string): { host: string; port: number } {
100 const m = /^\[?([^\]]*?)\]?:(\d+|\*)$/.exec(text.trim())
101 if (!m) return { host: cleanHost(text.trim()), port: 0 }
102 return { host: cleanHost(m[1]!), port: m[2] === '*' ? 0 : Number(m[2]) }
103}
104
105/** macOS and Linux both: internet sockets of `pids`, one record per line in lsof's -F format. */
106export const lsofNetArgv = (pids: readonly number[]) => ['lsof', '-nP', '-i', '-a', '-p', pids.join(','), '-FpPnT']
107
108/** Parses `lsof -FpPnT`: `p<pid>`, then per file `P<proto>`, `n<local>-><remote>`, `TST=<state>`. */
109export function socketsInLsof(text: string): Socket[] {
110 const out: Socket[] = []
111 let pid = 0
112 let proto: 'tcp' | 'udp' = 'tcp'
113 let current: Socket | null = null
114 for (const line of text.split('\n')) {
115 const tag = line[0]
116 const value = line.slice(1)
117 if (tag === 'p') pid = Number(value)
118 else if (tag === 'P') proto = value.toUpperCase() === 'UDP' ? 'udp' : 'tcp'
119 else if (tag === 'n') {
120 const [localText = '', remoteText = ''] = value.split('->')
121 const local = splitAddress(localText)
122 const remote = splitAddress(remoteText)
123 current = { pid, proto, local: local.host, localPort: local.port, remote: remote.host, remotePort: remote.port, state: '', bytesIn: -1, bytesOut: -1 }
124 out.push(current)
125 } else if (tag === 'T' && current && value.startsWith('ST=')) current.state = value.slice(3)
126 }
127 return out
128}
129
130/** Linux: every TCP and UDP socket with its owner and TCP byte counters, two lines each. */
131export const SS_ARGV = ['ss', '-tunpiH'] as const
132
133/** Parses `ss -tunpiH` for the sockets `pids` hold. */
134export function socketsInSs(text: string, pids: ReadonlySet<number>): Socket[] {
135 const out: Socket[] = []
136 let current: Socket | null = null
137 for (const raw of text.split('\n')) {
138 if (raw.trim() === '') continue
139 if (/^\s/.test(raw) && current) {
140 const sent = /bytes_acked:(\d+)/.exec(raw) ?? /bytes_sent:(\d+)/.exec(raw)
141 const received = /bytes_received:(\d+)/.exec(raw)
142 if (sent) current.bytesOut = Number(sent[1])
143 if (received) current.bytesIn = Number(received[1])
144 continue
145 }
146 current = null
147 const cols = raw.trim().split(/\s+/)
148 if (cols.length < 6) continue
149 const proto = cols[0] === 'udp' ? 'udp' : 'tcp'
150 const pidMatch = /pid=(\d+)/.exec(raw)
151 if (!pidMatch) continue
152 const pid = Number(pidMatch[1])
153 if (!pids.has(pid)) continue
154 const local = splitAddress(cols[4]!)
155 const remote = splitAddress(cols[5]!)
156 const state = cols[1] === 'ESTAB' ? 'ESTABLISHED' : cols[1] === 'UNCONN' ? '' : cols[1]!.replace('-', '_')
157 current = { pid, proto, local: local.host, localPort: local.port, remote: remote.host, remotePort: remote.port, state, bytesIn: -1, bytesOut: -1 }
158 out.push(current)
159 }
160 return out
161}
162
163/**
164 * Linux without `ss`: the socket inodes each process holds, then the kernel's tables. One `sh`
165 * so a single run reads it all; the script takes the pids as its arguments.
166 */
167export const procWalkArgv = (pids: readonly number[]) => [
168 'sh',
169 '-c',
170 'for p in "$@"; do echo "P $p"; ls -l /proc/$p/fd 2>/dev/null | grep -o "socket:\\[[0-9]*\\]"; done; echo TABLE; cat /proc/net/tcp /proc/net/tcp6 /proc/net/udp /proc/net/udp6 2>/dev/null',
171 'orbit',
172 ...pids.map(String),
173]
174
175const TCP_STATES: Record<string, string> = {
176 '01': 'ESTABLISHED', '02': 'SYN_SENT', '03': 'SYN_RECV', '04': 'FIN_WAIT1', '05': 'FIN_WAIT2', '06': 'TIME_WAIT',
177 '07': 'CLOSE', '08': 'CLOSE_WAIT', '09': 'LAST_ACK', '0A': 'LISTEN', '0B': 'CLOSING',
178}
179
180function hexAddress(text: string): { host: string; port: number } {
181 const [hexIp = '', hexPort = '0'] = text.split(':')
182 const port = parseInt(hexPort, 16)
183 if (hexIp.length === 8) {
184 const n = parseInt(hexIp, 16)
185 return { host: `${n & 0xff}.${(n >> 8) & 0xff}.${(n >> 16) & 0xff}.${(n >>> 24) & 0xff}`, port }
186 }
187 // Four little-endian 32-bit words
188 const words: string[] = []
189 for (let i = 0; i < 4; i++) {
190 const w = hexIp.slice(i * 8, i * 8 + 8)
191 const bytes = [w.slice(6, 8), w.slice(4, 6), w.slice(2, 4), w.slice(0, 2)]
192 words.push(bytes[0]! + bytes[1]!, bytes[2]! + bytes[3]!)
193 }
194 const v6 = words.map(w => parseInt(w, 16).toString(16)).join(':')
195 const mapped = /^0:0:0:0:0:ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(v6)
196 if (mapped) {
197 const hi = parseInt(mapped[1]!, 16)
198 const lo = parseInt(mapped[2]!, 16)
199 return { host: `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`, port }
200 }
201 return { host: v6, port }
202}
203
204export function socketsInProc(text: string): Socket[] {
205 const [owners = '', table = ''] = text.split('\nTABLE\n')
206 const inodeOwner = new Map<number, number>()
207 let pid = 0
208 for (const line of owners.split('\n')) {
209 if (line.startsWith('P ')) pid = Number(line.slice(2))
210 else {
211 const m = /socket:\[(\d+)\]/.exec(line)
212 if (m && pid) inodeOwner.set(Number(m[1]), pid)
213 }
214 }
215 const out: Socket[] = []
216 let proto: 'tcp' | 'udp' = 'tcp'
217 for (const line of table.split('\n')) {
218 const cols = line.trim().split(/\s+/)
219 if (cols[0] === 'sl') {
220 // Each table starts with its header; udp tables follow the tcp ones and have no state column meaning
221 continue
222 }
223 if (cols.length < 10) continue
224 const inode = Number(cols[9])
225 const owner = inodeOwner.get(inode)
226 if (!owner) continue
227 const local = hexAddress(cols[1]!)
228 const remote = hexAddress(cols[2]!)
229 const state = TCP_STATES[cols[3]!] ?? ''
230 proto = state === '' || cols[3] === '07' ? 'udp' : 'tcp'
231 out.push({ pid: owner, proto, local: local.host, localPort: local.port, remote: remote.host, remotePort: remote.port, state: proto === 'udp' ? '' : state, bytesIn: -1, bytesOut: -1 })
232 }
233 return out
234}
235
236/** macOS: bytes per connection from nettop, one sample, CSV, raw numbers. */
237export const nettopArgv = (pids: readonly number[]) => ['nettop', '-x', '-L', '1', '-J', 'bytes_in,bytes_out', ...pids.flatMap(p => ['-p', String(p)])]
238
239export type Counter = { local: string; localPort: number; remote: string; remotePort: number; bytesIn: number; bytesOut: number }
240
241/**
242 * Parses nettop's CSV: a header naming the columns, process rows (`claude.123`), and beneath each
243 * its connection rows (`tcp4 10.0.0.2:51234<->1.2.3.4:443`).
244 */
245export function countersInNettop(text: string): Counter[] {
246 const lines = text.split('\n').filter(l => l.trim() !== '')
247 const header = lines.find(l => l.includes('bytes_in'))
248 if (!header) return []
249 const cols = header.split(',')
250 const inAt = cols.indexOf('bytes_in')
251 const outAt = cols.indexOf('bytes_out')
252 const out: Counter[] = []
253 for (const line of lines) {
254 const cells = line.split(',')
255 const name = cells.find(c => c.includes('<->'))
256 if (!name) continue
257 const m = /(\S+)<->(\S+)/.exec(name)
258 if (!m) continue
259 const local = splitAddress(m[1]!)
260 const remote = splitAddress(m[2]!)
261 out.push({ local: local.host, localPort: local.port, remote: remote.host, remotePort: remote.port, bytesIn: Number(cells[inAt]) || 0, bytesOut: Number(cells[outAt]) || 0 })
262 }
263 return out
264}
265
266/** Whether a socket reaches beyond this machine: connected, to an address that is not its own. */
267export const isRemote = (s: Socket): boolean => s.remote !== '' && s.remotePort !== 0 && s.state !== 'LISTEN'
268hooks/rules.ts 95 lines1// Enforcement: rules over hosts, MCP servers and tools, decided before a tool runs. A host
2// pattern is a domain (`example.com` matches its subdomains too), a glob (`*.example.com`,
3// `api-*.example.com`), an address, or `*` for every host.
4
5import type { Intent } from './tools'
6
7export type Rule = {
8 /** `host:` a network host, `mcp:` an MCP server, `tool:` a tool name. */
9 kind: 'host' | 'mcp' | 'tool'
10 pattern: string
11 action: 'allow' | 'deny'
12 /** When it was added, ms since the epoch. */
13 at: number
14}
15
16/** `off` logs only; `denylist` blocks what a deny rule names; `allowlist` blocks all but allow rules; `ask` asks about the unknown. */
17export type Mode = 'off' | 'denylist' | 'allowlist' | 'ask'
18
19export const MODES: readonly Mode[] = ['off', 'denylist', 'allowlist', 'ask']
20
21export type Decision = {
22 action: 'allow' | 'deny' | 'ask'
23 /** The rule that decided, when one did. */
24 rule?: Rule
25 /** What was judged: the host, the server or the tool. */
26 subject: string
27 kind: Rule['kind']
28}
29
30/** Parses `host:example.com`, `mcp:github`, `tool:WebSearch`, or a bare pattern, which is a host. */
31export function parseSubject(text: string): { kind: Rule['kind']; pattern: string } | null {
32 const t = text.trim().toLowerCase()
33 if (t === '') return null
34 const m = /^(host|mcp|tool):(.+)$/.exec(t)
35 if (m) return { kind: m[1] as Rule['kind'], pattern: m[1] === 'tool' ? text.trim().slice(5) : m[2]! }
36 return { kind: 'host', pattern: t }
37}
38
39export function matchesPattern(pattern: string, subject: string): boolean {
40 const p = pattern.toLowerCase()
41 const s = subject.toLowerCase()
42 if (p === '*' || p === s) return true
43 if (p.includes('*')) {
44 const re = new RegExp('^' + p.split('*').map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$')
45 return re.test(s)
46 }
47 return s.endsWith('.' + p)
48}
49
50/** The rule deciding `subject`: the most specific match (longest pattern), newest among equals. */
51export function ruleFor(rules: readonly Rule[], kind: Rule['kind'], subject: string): Rule | undefined {
52 return rules
53 .filter(r => r.kind === kind && matchesPattern(r.pattern, subject))
54 .sort((a, b) => b.pattern.length - a.pattern.length || b.at - a.at)[0]
55}
56
57/**
58 * Decides every subject a call reaches. The first deny wins; in `allowlist` mode a subject no
59 * allow rule names is denied; in `ask` mode it is asked about; `off` allows everything.
60 */
61export function decide(rules: readonly Rule[], mode: Mode, intent: Intent): Decision[] {
62 const subjects: { kind: Rule['kind']; subject: string }[] = [{ kind: 'tool', subject: intent.tool }]
63 if (intent.kind === 'mcp') subjects.push({ kind: 'mcp', subject: intent.server })
64 for (const h of intent.hosts) subjects.push({ kind: 'host', subject: h })
65 if (intent.kind === 'search') subjects.push({ kind: 'host', subject: 'search' })
66
67 const out: Decision[] = []
68 for (const { kind, subject } of subjects) {
69 const rule = ruleFor(rules, kind, subject)
70 if (mode === 'off') out.push({ action: 'allow', subject, kind, rule })
71 else if (rule) out.push({ action: rule.action, rule, subject, kind })
72 else if (kind === 'tool') out.push({ action: 'allow', subject, kind }) // a tool is judged by what it reaches
73 else if (mode === 'allowlist') out.push({ action: 'deny', subject, kind })
74 else if (mode === 'ask') out.push({ action: 'ask', subject, kind })
75 else out.push({ action: 'allow', subject, kind })
76 }
77 return out
78}
79
80export const verdict = (decisions: readonly Decision[]): Decision | undefined =>
81 decisions.find(d => d.action === 'deny') ?? decisions.find(d => d.action === 'ask')
82
83export function describeRule(r: Rule): string {
84 return `${r.action === 'deny' ? 'block' : 'allow'} ${r.kind}:${r.pattern}`
85}
86
87/** Adds or replaces the rule for a subject. */
88export function withRule(rules: readonly Rule[], rule: Rule): Rule[] {
89 return [...rules.filter(r => !(r.kind === rule.kind && r.pattern === rule.pattern)), rule]
90}
91
92export function withoutRule(rules: readonly Rule[], kind: Rule['kind'], pattern: string): Rule[] {
93 return rules.filter(r => !(r.kind === kind && r.pattern === pattern))
94}
95hooks/tools.ts 73 lines1// The tool layer: what a tool call means to reach, read from its input before it runs. WebFetch
2// names a URL, WebSearch a search, an MCP tool its server, and a Bash command whatever hosts its
3// text names (curl, git, ssh, scp, nc, wget, pip, npm...), found best effort.
4
5import { normalizeHost } from './geo'
6
7export type Intent = {
8 tool: string
9 /** `host` for a network host, `mcp` for an MCP server, `search` for a web search, `none` for a tool that reaches nothing. */
10 kind: 'host' | 'mcp' | 'search' | 'none'
11 hosts: string[]
12 /** The MCP server's name, for `mcp`. */
13 server: string
14 /** What the call said, shortened for the log: the URL, the query, the command. */
15 summary: string
16}
17
18const URL_RE = /\b(?:https?|wss?|ftp|git|ssh|git\+ssh|ssh\+git|smb|rsync):\/\/(?:[^\s@/'"`]*@)?(\[[0-9a-f:.]+\]|[a-z0-9._-]+)(?::\d+)?/gi
19const SCP_RE = /(?:^|[\s;&|(])(?:ssh|scp|sftp|rsync|git\s+clone|git\s+push|git\s+pull|git\s+fetch)\s+(?:-\S+\s+)*(?:[a-z0-9._-]+@)?([a-z0-9][a-z0-9.-]+\.[a-z]{2,}|\[[0-9a-f:.]+\]|\d{1,3}(?:\.\d{1,3}){3})(?::|\s|$)/gi
20const TOOL_HOST_RE = /(?:^|[\s;&|(])(?:nc|ncat|netcat|telnet|ping|ping6|dig|nslookup|host|traceroute|mtr|openssl\s+s_client\s+-connect|psql\s+-h|mysql\s+-h|redis-cli\s+-h|mongosh|curl|wget|http|https)\s+(?:-[-\w=]+\s+)*(\[[0-9a-f:.]+\]|[a-z0-9][a-z0-9.-]+\.[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?(?:\s|$|\/)/gi
21const BARE_HOST_RE = /(?:^|[\s=@'"`(])((?:[a-z0-9-]+\.)+(?:com|org|net|io|dev|ai|co|edu|gov|app|sh|me|info|cloud|tech|xyz|us|uk|de|fr|jp|cn|in|ca|au|nl|se|ch|eu|ru|br))(?::\d+)?(?=[\s/:'"`)]|$)/gi
22
23/** The hosts a shell command names, in order of appearance, each once. */
24export function hostsInCommand(command: string): string[] {
25 const found: string[] = []
26 const add = (raw: string | undefined) => {
27 if (!raw) return
28 const host = normalizeHost(raw)
29 if (host && host !== 'localhost' && !found.includes(host)) found.push(host)
30 }
31 for (const re of [URL_RE, SCP_RE, TOOL_HOST_RE, BARE_HOST_RE]) {
32 re.lastIndex = 0
33 for (let m = re.exec(command); m; m = re.exec(command)) add(m[1])
34 }
35 return found
36}
37
38export function hostOfUrl(url: string): string {
39 try {
40 return normalizeHost(new URL(url).hostname)
41 } catch {
42 const m = /^(?:[a-z]+:\/\/)?([^/\s:]+)/i.exec(url.trim())
43 return m ? normalizeHost(m[1]!) : ''
44 }
45}
46
47const shorten = (s: string, n = 96) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
48
49/** What a tool call reaches, from its input; the fields are read loosely since inputs vary by tool. */
50export function intentOf(input: { tool: string } & Record<string, unknown>): Intent {
51 const tool = String(input.tool)
52 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
53 if (tool === 'WebFetch') {
54 const host = hostOfUrl(text('url'))
55 return { tool, kind: 'host', hosts: host ? [host] : [], server: '', summary: shorten(text('url')) }
56 }
57 if (tool === 'WebSearch') {
58 return { tool, kind: 'search', hosts: [], server: '', summary: shorten(`search: ${text('query')}`) }
59 }
60 if (tool === 'Bash') {
61 const command = text('command')
62 return { tool, kind: 'host', hosts: hostsInCommand(command), server: '', summary: shorten(command.replace(/\s+/g, ' ')) }
63 }
64 const mcp = /^mcp__([^_].*?)__(.+)$/.exec(tool)
65 if (mcp) {
66 return { tool, kind: 'mcp', hosts: [], server: mcp[1]!, summary: shorten(`${mcp[1]}: ${mcp[2]}`) }
67 }
68 return { tool, kind: 'none', hosts: [], server: '', summary: '' }
69}
70
71/** Whether a tool is one the tracer watches at all. */
72export const isTraced = (tool: string): boolean => tool === 'WebFetch' || tool === 'WebSearch' || tool === 'Bash' || tool.startsWith('mcp__')
73hooks/trace.ts 247 lines1// The trace: one event per thing a session reached, from whichever layer saw it, with the
2// correlation between layers, the JSONL it exports to, and the arcs a moment of it draws.
3
4import type { Arc, LatLon } from './globe'
5import { BLOCKED, INBOUND, OUTBOUND, SESSION_COLORS } from './globe'
6import type { Place } from './geo'
7
8export type Layer = 'tool' | 'socket' | 'proxy' | 'stream'
9
10export type Status = 'open' | 'closed' | 'done' | 'blocked' | 'asked' | 'failed'
11
12export type TraceEvent = {
13 id: string
14 /** ms since the epoch when it began. */
15 t: number
16 /** ms since the epoch of the last byte or change. */
17 last: number
18 /** The session's pid; 0 when unknown. */
19 sessionPid: number
20 /** The session's label: the folder and terminal it runs in. */
21 session: string
22 layer: Layer
23 /** The tool that caused it, when known: WebFetch, Bash, mcp__server__tool... */
24 tool: string
25 /** What the call said: the URL, the query, the command; or the socket's peer. */
26 summary: string
27 host: string
28 ip: string
29 port: number
30 direction: 'out' | 'in'
31 bytesIn: number
32 bytesOut: number
33 place: Place | null
34 status: Status
35 /** The process holding the socket. */
36 pid: number
37 cmd: string
38 /** The id of the event in another layer this one was matched to. */
39 linked: string
40 /** Why it was blocked or what the rule was. */
41 note: string
42}
43
44export const MAX_EVENTS = 600
45
46let counter = 0
47export const nextId = (t: number) => `${t.toString(36)}-${(counter++ % 46656).toString(36)}`
48
49export function newEvent(fields: Partial<TraceEvent> & { t: number; layer: Layer }): TraceEvent {
50 return {
51 id: nextId(fields.t),
52 last: fields.t,
53 sessionPid: 0,
54 session: '',
55 tool: '',
56 summary: '',
57 host: '',
58 ip: '',
59 port: 0,
60 direction: 'out',
61 bytesIn: 0,
62 bytesOut: 0,
63 place: null,
64 status: 'open',
65 pid: 0,
66 cmd: '',
67 linked: '',
68 note: '',
69 ...fields,
70 }
71}
72
73/** Keeps the newest events; the oldest finished ones go first. */
74export function bounded(events: readonly TraceEvent[]): TraceEvent[] {
75 if (events.length <= MAX_EVENTS) return [...events]
76 const open = events.filter(e => e.status === 'open')
77 const rest = events.filter(e => e.status !== 'open').slice(-(MAX_EVENTS - open.length))
78 return [...rest, ...open].sort((a, b) => a.t - b.t)
79}
80
81const CORRELATE_MS = 4000
82
83/**
84 * Matches a socket event to the tool call that caused it: the newest tool event of the same
85 * session within the window that names a host and has no socket yet (or names this host).
86 * A Bash command's sockets belong to its child processes and a WebFetch's to Claude Code
87 * itself, so the socket's owner has to fit the tool; Anthropic's own addresses never match a
88 * call to anyone else.
89 */
90export function correlate(events: readonly TraceEvent[], socket: TraceEvent): { tool: TraceEvent; host: string } | null {
91 const isAnthropicAddress = socket.place?.anycast === 'Anthropic'
92 const isMainProcess = socket.pid === socket.sessionPid
93 for (let i = events.length - 1; i >= 0; i--) {
94 const e = events[i]!
95 if (socket.t - e.t > CORRELATE_MS * 3) break
96 if (e.layer !== 'tool' || e.sessionPid !== socket.sessionPid) continue
97 if (e.status === 'blocked') continue
98 if (socket.t < e.t - 500 || (e.status !== 'open' && socket.t - e.last > CORRELATE_MS)) continue
99 if (e.host === '' || e.host.startsWith('mcp:') || e.host === 'web search') continue
100 if (e.linked !== '' && e.ip !== socket.ip) continue
101 if (e.tool === 'Bash' && isMainProcess) continue
102 if ((e.tool === 'WebFetch' || e.tool === 'WebSearch') && !isMainProcess) continue
103 if (isAnthropicAddress && !/anthropic|claude/i.test(e.host)) continue
104 return { tool: e, host: e.host }
105 }
106 return null
107}
108
109export function toJsonl(events: readonly TraceEvent[]): string {
110 return events.map(e => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')
111}
112
113export function fromJsonl(text: string): TraceEvent[] {
114 const out: TraceEvent[] = []
115 for (const line of text.split('\n')) {
116 if (line.trim() === '') continue
117 try {
118 const raw = JSON.parse(line) as Partial<TraceEvent>
119 if (typeof raw.t !== 'number' || typeof raw.layer !== 'string') continue
120 out.push({ ...newEvent({ t: raw.t, layer: raw.layer as Layer }), ...raw, id: raw.id ?? nextId(raw.t) })
121 } catch {
122 // a torn line
123 }
124 }
125 return out.sort((a, b) => a.t - b.t)
126}
127
128export type Summary = { countries: string[]; orgs: string[]; hosts: string[]; bytesIn: number; bytesOut: number; blocked: number; sessions: number }
129
130export function summarize(events: readonly TraceEvent[]): Summary {
131 const countries = new Set<string>()
132 const orgs = new Set<string>()
133 const hosts = new Set<string>()
134 const sessions = new Set<number>()
135 let bytesIn = 0
136 let bytesOut = 0
137 let blocked = 0
138 for (const e of events) {
139 if (e.place?.country) countries.add(e.place.country)
140 if (e.place?.org) orgs.add(e.place.org)
141 if (e.host) hosts.add(e.host)
142 else if (e.ip) hosts.add(e.ip)
143 if (e.sessionPid) sessions.add(e.sessionPid)
144 bytesIn += Math.max(0, e.bytesIn)
145 bytesOut += Math.max(0, e.bytesOut)
146 if (e.status === 'blocked') blocked++
147 }
148 return { countries: [...countries].sort(), orgs: [...orgs].sort(), hosts: [...hosts].sort(), bytesIn, bytesOut, blocked, sessions: sessions.size }
149}
150
151export function formatBytes(n: number): string {
152 if (n < 0) return '–'
153 if (n < 1024) return `${n}b`
154 if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)}k`
155 if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`
156 return `${(n / 1024 / 1024 / 1024).toFixed(2)}G`
157}
158
159export type ArcOptions = {
160 now: number
161 fadeMs: number
162 home: LatLon
163 /** Color each session apart (observe-all) instead of by direction. */
164 bySession: boolean
165 selectedId: string
166 /** Events whose Anthropic stream is generating now pulse. */
167 isGenerating: boolean
168}
169
170/** A stable color per session pid in observe-all mode. */
171export function sessionColor(sessionPid: number, sessionPids: readonly number[]): number {
172 const i = sessionPids.indexOf(sessionPid)
173 return SESSION_COLORS[(i < 0 ? 0 : i) % SESSION_COLORS.length]!
174}
175
176export const isAnthropic = (e: TraceEvent): boolean => /anthropic/i.test(e.host) || /anthropic/i.test(e.place?.anycast ?? '') || e.layer === 'stream'
177
178/** The arcs a moment of the trace draws: every placed event still within its fade. */
179export function arcsFor(events: readonly TraceEvent[], options: ArcOptions): Arc[] {
180 const sessionPids = [...new Set(events.map(e => e.sessionPid))].sort((a, b) => a - b)
181 const out: Arc[] = []
182 for (const e of events) {
183 if (!e.place || (e.place.lat === 0 && e.place.lon === 0)) continue
184 if (e.t > options.now) continue
185 const isLive = e.status === 'open' && e.t <= options.now
186 const age = options.now - (isLive ? Math.max(e.last, options.now - 1) : e.last)
187 if (!isLive && age > options.fadeMs) continue
188 const fade = isLive ? 1 : 1 - age / options.fadeMs
189 const bytes = Math.max(0, e.bytesIn) + Math.max(0, e.bytesOut)
190 const weight = Math.min(1, Math.log10(bytes + 1) / 6)
191 const recent = options.now - e.last < 1500
192 const breathing = isLive && (recent || (options.isGenerating && isAnthropic(e)))
193 const color = e.status === 'blocked' ? BLOCKED : options.bySession ? sessionColor(e.sessionPid, sessionPids) : e.direction === 'in' ? INBOUND : OUTBOUND
194 const to = { lat: e.place.lat, lon: e.place.lon }
195 out.push({
196 from: e.direction === 'in' ? to : options.home,
197 to: e.direction === 'in' ? options.home : to,
198 color,
199 strength: Math.max(0.15, Math.min(1, fade * (0.55 + 0.45 * weight) + (breathing ? 0.25 * Math.sin((options.now % 1000) / 1000 * 2 * Math.PI) : 0))),
200 progress: e.status === 'blocked' ? 0.35 : Math.min(1, (options.now - e.t) / 700),
201 pulse: breathing ? ((options.now % 1200) / 1200) : -1,
202 thickness: weight > 0.75 ? 3 : weight > 0.4 ? 2 : 1,
203 isDashed: e.status === 'blocked' || (options.bySession && e.direction === 'in'),
204 isSelected: e.id === options.selectedId,
205 })
206 }
207 return out
208}
209
210/** A replay of a recorded trace: a position on its timeline, moving at `speed` while playing. */
211export type Replay = {
212 file: string
213 events: TraceEvent[]
214 start: number
215 end: number
216 position: number
217 speed: number
218 isPlaying: boolean
219}
220
221export function openReplay(file: string, events: TraceEvent[]): Replay | null {
222 if (events.length === 0) return null
223 const start = events[0]!.t - 1000
224 const end = Math.max(...events.map(e => Math.max(e.t, e.last))) + 5000
225 return { file, events, start, end, position: start, speed: 4, isPlaying: true }
226}
227
228/** The replay `dtMs` later: the position advances while playing and stops at the end. */
229export function stepReplay(r: Replay, dtMs: number): Replay {
230 if (!r.isPlaying) return r
231 const position = Math.min(r.end, r.position + dtMs * r.speed)
232 return { ...r, position, isPlaying: position < r.end }
233}
234
235/** Events as they stood at `position`: begun by then, with their later changes hidden. */
236export function eventsAt(r: Replay, position: number): TraceEvent[] {
237 return r.events
238 .filter(e => e.t <= position)
239 .map(e => (e.last > position ? { ...e, last: position, status: 'open' as Status } : e))
240}
241
242export function scrubber(r: Replay, width: number): string {
243 const n = Math.max(4, width)
244 const at = Math.round(((r.position - r.start) / Math.max(1, r.end - r.start)) * (n - 1))
245 return Array.from({ length: n }, (_, i) => (i < at ? '━' : i === at ? '●' : '─')).join('')
246}
247hooks/land.ts 452 lines1// The world's land as a 720 × 360 bit mask in equirectangular projection: row-major from
2// 90°N 180°W, one bit per half degree, most significant bit first. Rasterized once from Natural
3// Earth country outlines (johan/world.geo.json, public domain) by docs/landmask.py.
4
5export const LAND_WIDTH = 720
6export const LAND_HEIGHT = 360
7
8const MASK = Uint8Array.fromBase64(
9 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
10 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
11 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
12 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
13 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
14 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
15 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
16 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
17 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
18 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
19 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
20 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
21 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
22 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
23 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
24 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
25 'AAAAAAB////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
26 'AAAAAAAAAAAAH//////gAAAAD////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
27 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////////gAAB8P/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
28 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///////+Af////////////8A/4AAAAAAAAAAAAAAAAAAAAAAAA' +
29 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////+A/////////////3//+AAAA' +
30 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf8P//////g//' +
31 '//////////////+AAAAAAAAAAAAAAAAAAAAHvAAAAAAAAAAAAAB/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
32 'AAAAAAAB//+AP///8H///////////////+AAAAAAAAAAAB//+AAAAAAHwAAAAAAAAAAAAAD//AAAAAAAAAAAAAAAAAAAAAAAAAAA' +
33 'AAAAAAAAAAAAAAAAAAAAAAAAAAAA///f////AAf//////////////+AAAAAAAAAD4/D/+AAAAAAAAAAAAAAAAAAAAAA//wAAAAAA' +
34 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAB//z//8AAD///////////////8AAAAAAAAAD//8AAAAAAAAAAAAA' +
35 'AAAAAAAAAAAP/xwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAf4eD/8///8AH////////////////4AAAAAA' +
36 'AAAB//8AAAAAAAAAAAAAAAAAAAAAAAAAAH/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8fgHA///8D////' +
37 '/////////////4AAAAAAAAAAf/w8AAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
38 'AAfwAAAB8ADn//AAf////////////////4AAAAAAAAAAB/A/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
39 'AAAAAAAAAAAAAAAAAAAA/gAAAAAAAAD//4AAB////////////////8AAAAAAAAAAD+AAAAAAAAAAAAAAAAAAAAAAAAAAAA/gAAAA' +
40 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/gAEAAAD/4f///AAH////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAA' +
41 'AA+AAAAAAAAAAD/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfwPgPAB/4B8AAAwAAA////////////////gAAAAAA' +
42 'AAAAAAAAAAAAAAAAAAAAP/8AAAAAAAAAD////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+H8B/4A/gAcAAAAAAA' +
43 'f////////////4AAAAAAAAAAAAAAAAAAAAAAAAAH/4AAAAAAAAB///////AAAAAAAB//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
44 'B///8APx8f///wAAAAAAH////////////4AAAAAAAAAAAAAAAAAAAAAAAAB/4AAAAAAAAH///////+AAAAAAAD//z8AAAAAAAAAA' +
45 'AAAAAAAAAAAAAAAAAAAAAAfgAAAA8H///wAAAAAAB////////////wAAAAAAAAAAAAAAAAAAAAAAAAD/AAAAAAAAB////////gAA' +
46 'AAAAAAcAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/AAAAAAAAAAAAAAAAAAAAA////////////AAAAAAAAAAAAAAAAAAAAAAAAAP4' +
47 'AAAAAAAAB///////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAAEAf+AQAQAAAAAAAP///////////gAAAAAA' +
48 'AAAAAAAAAAAAAAAAAB/gAAAAAAAf/////////8AAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//kAAHgf4/4Pj+fwAAAAA' +
49 'AH//////////gAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAAAAB////////////8A/8AAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/' +
50 '5+8Y+B38/A/3/gAAAAAAAD//////////4AAAAAAAAAAAAAAAAAAAAAAAAH8AAAA/hgB////////////////AAADwAAAAAAAAAAAA' +
51 'AAAAAAAAAAAAAAAAAAf/n//+/Af+cA///n+AAAAAAD/////////+AAAAAAAAAAAAAAAAAAAAAAAAAH4AAAA/wh8/////////////' +
52 '///AAAH//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAf+P//+/AD+cA/////wAAAAAH//////////4AAAAAAAAAAAAAAAAAAAAAAAAf4A' +
53 'AAD/Dv/////////////////AgAH///AAAAAAAAAA+AAAAAADAAAAAAAAAAB8Af///gAB/A//////gAAAAAP////////78AAAAAAA' +
54 'AAAAAAAAAAAAAAAAAD+AAAP/H3/////////////////h//////4AAAAAAAADAAAAAAB/7wAAAAAAAAAAD////4AB/wf/////8AAA' +
55 'ABz////////88AAAAAAAAAAAAAAA//AAAAAAAA/gAAf/j////////////////////////////AAAAAAAAAAAAA////8AAAAAAMAA' +
56 'AAf///AB/4A//////wAAAB/////////gAAAAAAAAAAAAAAAD//gAAAAAAAAAAAP/z////////////////////////////wAAAAAA' +
57 'AAAAAD//////+AAA/+I4A/////w8fgAHhAf//4AAAB+/////////gAAAAAAAAAAAAAP///2AAAAAAAAAYAP/n///////////////' +
58 '/////////////gAAD/+AAAAAAH///////+D/////wL///+B/H+AH+AH//8AAAAA////////8AAAAAAAAAAAAAA/////4AAAAAAAA' +
59 'f8D/j///////////////////////////////h//8gAAAH////////////////8f4EAAAH+4H/AD//gAAAAB////////AAAAAAAAA' +
60 'AAAAAD//////4AAAABgEP/g/w///////////////////////////////7///+AAAH////////////////+AB/AAfH/8P8AAP/8AA' +
61 'AAP///////gAAAAAAAAAAAAAAf///////AD4A+P///9/5////////////////////////////////////gAAAf//////////////' +
62 '//D4//H///8f+APD//4AAAf//////AAAAAAAAAAAAAAAB////////4D8P///////w///////////////////////////////////' +
63 '/8AAAP//////////////////////////+AOB//8AAA//////+AAAAAAAAAAAAAAAD////////8Dg////////j///////////////' +
64 '/////////////////////9+AAB//////////////////////////4AAH///wAAf/////8AAAAAAAAAAAAAAAH////////8Bz////' +
65 '////D//////////////////////////////////////gD4/////////////////////////wAAAP/x/wAAf/////gAAAcAPgAAAA' +
66 'AAAAP//////wPx7/////////f///////////////////////////////////P//g///////////////////////////gAAAP/wfA' +
67 'AAP////wAAAA///gAAAAAAAA///4f//8AH//////////////////////////////////////////////gP4AP///////////////' +
68 '///////////PAA///4GAAAH///8AAAAAD//wAAAAAAAB///gH//8AP//////////////////////////////////////////////' +
69 'AD4AH/v///////////////////////+P4B////AAAAD///4AAAAAf//gAAAAAAAD///gf//8eH//////////////////////////' +
70 '///////////////////+AAAAAAP///////////////////////8f+AgH//wAAAD///4AAAAAD/+AAAAAAAAP///B///+P///////' +
71 '///////////////////////////////////////wAAAAAAP//////////////////////+A/HwAA//wAAAB///wAAAAAA/gAAAAA' +
72 'AAA///8H///////////////////////////////////////////////////4AAAeAB///////////////////////+AEAAAA/z4A' +
73 'AAA///gAAAAAAAAAAAAAAAD///wf///////////////////////////////////////////////////8AAAAA///////////////' +
74 '/////////wAAcAAAH8QAAAAf/+AAAAAAAAAAAAAAAAf///A////////////////////////////////////////////////////+' +
75 'AAAAB////////////////////////AAAwI+YAeAAAAAP/+AAAAAAAAAAAAAAAB///+Af////////////////////////////////' +
76 '//////////////x///+AAAAAD///////////////////////+AAAAI//AAAAAAAH/+AAAAAAAAAAAAAAAD///+Af////////////' +
77 '/////////////////////////////////fj///gAAAAAB///////////////////////8AAAAA//gAAAAAAD/8AAAAAAAAAAAAAA' +
78 'AD///8Af////////////////////////////////////////////gfH//8AAAAAAB////38f////////////////4AAAAA//+AAA' +
79 'AAAAD8AAAAAAAAAAAAAAAD///+Af///////////////////////////////////////////+AIP//gAAAAAAOf///n8D////////' +
80 '////////4AAAAAf/+AAAAAAAA4AAAAAAAAAAAAAAAD////gH4H/////////////////////////////////////////8AB//GAAA' +
81 'AAAAAA///PgAA///////////////4AAAAAf/+AcAAAAAAAAAAAAAAAAAAAAAAB////gAAH//////////////////////////////' +
82 '///////////wAD5AAAAAAAAAAA//8EAAAH//////////////4AAAAA//+A+AAAAAAAAAAAAAAAAAAAAAAB/3//AB////////////' +
83 '////////////////////////////g8H8APwAAAAAAAAAAAx7+AAAAB///////////////AAAAB///g/AAAAAAAAAAAAAAAAAAAAA' +
84 'AB/D/8AB///////////////////////////////////////gAAAAAfgAAAAAAAAAAAAH8AAAAAP//////////////AAAAAf////g' +
85 'AAAAAAAAAAAAAAAAAAeAAAcB/4AAf//////////////////////////////////////AAAAAB/AAAAAAAAAAAAAHjgAAAAAP////' +
86 '/////////gAAAAP////wAAAAAAAAAAAAAAAAAA8AAAAB/4QAf/////////////////////////////////////8AAAAAP/gAAAAA' +
87 'AAAAAAAeHAAAAAAH/////////////iAAAAH////4AAAAAAAAAAAAAAAAAA/wAAAY/4gc////////////////////////////////' +
88 '//////wAAAAAf/wAAAAAAAAAAAB4AAAAAAAD//////////////wAAAH////4AAAAAAAAAAAAAAAAAA/gAAD4f8A/////////////' +
89 '//////////////////////////gAAAAA//wAAAAAAAAAAAHgAAAAAAAA//////////////+AAAP////4AAAAAAAAAAAAAAAAAAfA' +
90 'AAD8fwA//////////////////////////////////////+AAAAAB//wAAAAAAAAAAA/AAAAAAAAA///////////////wAAP////+' +
91 'AAAAAAAAAAAAAAAAAAfwAADzuAA//////////////////////////////////////8AAAAAB//AAAAAAAAAAADgAAAAAAAAAf///' +
92 '////////////gA//////gAAAAAAAAAAAAAAAAAP4AABxgAAf/////////////////////////////////////wAAAAAB/+AAAAAA' +
93 'AAAAAcAAAAAAAAAAH///////////////4H//////+AAAAAAAAAAAAAAAAPH8AABwADD/////////////////////////////////' +
94 '/////AAAAAAB//AAAAAAAAAAAAAAAAAAAAAAH///////////////4H///////AAAAAAAAAAAAAAAA/A+AAB8wf//////////////' +
95 '/////////////////////////8AAAAAB/4AAAAAAAAAAAAAAAAAAAAADB///////////////4D///////AAAAAAAAAAAAAAAB/A/' +
96 'AAB///////////////////////////////////////////+EAAAA/wAAAAAAAAAAAAAAAAAAAAADA///////////////8D//////' +
97 '/wAAAAAAAAAAAAAAB/H/AD/////////////////////////////////////////////cAAAA/wAAAAAAAAAAAAAAAAAAAAABA///' +
98 '////////////8B///////4AAAAAAAAAAAAAAB+H/4H/////////////////////////////////////////////+AAAA+AAAAAAA' +
99 'AAAAAAAAAAAAAAAAAP//////////////+D///////4AAAAAAAAAAAAAAB8H/4P//////////////////////////////////////' +
100 '///////8AAAAeAAAAAAAAAAAAAAAAAAAAAAAAP///////////////D///////gAAAAAAAAAAAAAABgP/wP//////////////////' +
101 '///////////////////////////cAAAAcAAAAAAAAAAAAAAAAAAAAAAAAH//////////////////////84AAAAAAAAAAAAAAAAD/' +
102 '4/////////////////////////////////////////////+OAAAAQAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////////' +
103 'hwAAAAAAAAAAAAAAAAP8H/////////////////////////////////////////////+OAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//' +
104 '//////////////////B/BgAAAAAAAAAAAAAAAAaAH/////////////////////////////////////////////+PAAAAAAAAAAAA' +
105 'AAAAAAAAAAAAAAAAAD3//////////////////8HAD4AAAAAAAAAAAAAAAAAA////////////////////////////////////////' +
106 '//////+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5//////////////////wAwH/gAAAAAAAAAAAAAAAAP////////////////////' +
107 '//////////////////////////+NgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAef/////////////////H8AP/gAAAAAAAAAAAAAAADn' +
108 '//////////////////////////////////////////////8MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEf////////////////+fwA' +
109 'P/wAAAAAAAAAAAAAAAH///////////////////////////////////////////////8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHf' +
110 '////////////////9/wAf/wAAAAAAAAAAAAAAAB///////////////////////////////////////////////4IAAAAAAAAAAAA' +
111 'AAAAAAAAAAAAAAAAAAD/////////////////7/wAAEwAAAAAAAAAAAAAAAAf////////////////////////////////////////' +
112 '//////wMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////////////////8AAAwAAAAAAAAAAAAAAAAP////////////z///+D//' +
113 '//////////////////////////gOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////////////////5yAAAAAAAAAAAAAAAAAAAH' +
114 '//////////h8D///wD////////////////////////////AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////' +
115 'AAAAAAAAAAAAAAAAAAAD//////////AcH///gD///////////////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/' +
116 '//////////////////H8AAAAAAAAAAAAAAAAAAAH////n////+B////+Af///////////////////////////8AIAAAAAAAAAAAA' +
117 'AAAAAAAAAAAAAAAAAAD//////////////////8fAAAAAAAAAAAAAAAAAAAAH////g////8AcH//8Af//////////////////////' +
118 '/////4AcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////////g4AAAAAAAAAAAAAAAAAAAAH//+/g////4AAB//8B///' +
119 '/////////////////////////wAeIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////8AwAAAAAAAAAAAAAAAAAAAAH' +
120 '//8Hwf///4AAAf/+A////////////////////////////gAf4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/////////////////4AAA' +
121 'AAAAAAAAAAAAAAAAA////xgH4D///wAAAP/+Af//////////////////////////eAB/4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/' +
122 '////////////////wAAAAAAAAAAAAAAAAAAAA////AAn8A///wAAAB/+AH/////////////////////////+AAD/AAAAAAAAAAAA' +
123 'AAAAAAAAAAAAAAAAAAH/////////////////wAAAAAAAAAAAAAAAAAAAA////ABh+AP//wAAAB//AH//////////////////////' +
124 '///8AADmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////8AAAAAAAAAAAAAAAAAAAA////ABg/wH//wB/AB//gE//' +
125 '///////////////////////wAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD////////////////+AAAAAAAAAAAAAAAAAAAAA///' +
126 '8AAAP4H//8H/wD//wAf////////////////////////gAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////////////////8AAAA' +
127 'AAAAAAAAAAAAAAAAA///gADgD+H/Dj//////4C//////////////////////z//gAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/' +
128 '///////////////wAAAAAAAAAAAAAAAAAAAAA///AABwA4n7g///////+D//////////////////////D/+AAADwAAAAAAAAAAAA' +
129 'AAAAAAAAAAAAAAAAAAD////////////////wAAAAAAAAAAAAAAAAAAAAA///AABgAYD8B///////4B/////////////////////+' +
130 'Hn4AAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////////////+gAAAAAAAAAAAAAAAAAAAAB///AABgAMB+B///////4D//' +
131 '///////////////////8GB4AAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////////////////AAAAAAAAAAAAAAAAAAAAAB///' +
132 'AAAAAIB8B///////wA/////////////////////gAD8AAAHgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////////////7AAAAA' +
133 'AAAAAAAAAAAAAAAAA//+AAAAAYA/B///////wA/////////////////////wAD+AAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf' +
134 '//////////////6AAAAAAAAAAAAAAAAAAAAAA//+AAAAfgAdA///////wA/////////////////////8QA/AAAfAAAAAAAAAAAAA' +
135 'AAAAAAAAAAAAAAAAAAAf//////////////4AAAAAAAAAAAAAAAAAAAAAA//4AAAwHgAcAf//////+A/////////////////////+' +
136 '+AfgAE/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////8AAAAAAAAAAAAAAAAAAAAAAB/wA9/8AAAMAf/7/////3//' +
137 '////////////////////4A/gAH/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////////////8AAAAAAAAAAAAAAAAAAAAAAAwA' +
138 'f//4AAAAABhw////////////////////////////wAfgAP+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//////////////8AAAAA' +
139 'AAAAAAAAAAAAAAAAAAwD///8AAAAAAAA////////////////////////////AAfgAP+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD' +
140 '//////////////8AAAAAAAAAAAAAAAAAAAAAAA/P///8AAAB+AAw///////////////////////////+AAfAf/+AAAAAAAAAAAAA' +
141 'AAAAAAAAAAAAAAAAAAAB//////////////4AAAAAAAAAAAAAAAAAAAAAAB/////4AAAAAABg///////////////////////////+' +
142 'AAcA//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////////////gAAAAAAAAAAAAAAAAAAAAAAD/////wAAAAAAAB////////' +
143 '////////////////////gAADy8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////////////+AAAAAAAAAAAAAAAAAAAAAAAH//' +
144 '///8AAAAAAAB////////////////////////////gAAEeQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////////////8AAAAAA' +
145 'AAAAAAAAAAAAAAAAAf/////8AAAAAAAD////////////////////////////gAAfYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
146 'A////////////wAAAAAAAAAAAAAAAAAAAAAAA///////4AAwAAAD////////////////////////////wAAfAAAAAAAAAAAAAAAA' +
147 'AAAAAAAAAAAAAAAAAAAAA////////////gAAAAAAAAAAAAAAAAAAAAAAB////////gD8AAAD////////////////////////////' +
148 '4AAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9///////////AAAAAAAAAAAAAAAAAAAAAAAB////////gD/wAAH////////' +
149 '////////////////////8AAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcf/////////+AAAAAAAAAAAAAAAAAAAAAAAD///' +
150 '/////4D//g8H////////////////////////////8AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMP/////////+AAAAAAA' +
151 'AAAAAAAAAAAAAAAAB/////////D/////////////////////////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
152 'AOP///////gP+AAAAAAAAAAAAAAAAAAAAAAAB///////////////////////////////////////////4AAAAAAAAAAAAAAAAAAA' +
153 'AAAAAAAAAAAAAAAAAAAAAGH/////+/gEfAAAAAAAAAAAAAAAAAAAAAAAB///////////////////B///////////////////////' +
154 '8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADH/////wDAAPAAAAAAAAAAAAAAAAAAAAAAAD//////////////7////B///' +
155 '////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABj/////gAAAPgAAAAAAAAAAAAAAAAAAAAAAP///' +
156 '//////////+7////g///////////////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAx////+AAAAHgAAAAAA' +
157 'AAAAAAAAAAAAAAAAf//////////////f////gf//////////////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
158 'AD4f///8AAAAPgAAAAAAAAAAAAAAAAAAAAAD///////////////h////wP//////////////////////wAAAAAAAAAAAAAAAAAAA' +
159 'AAAAAAAAAAAAAAAAAAAAAB4f///4AAAAHwAAAAAAAAAAAAAAAAAAAAAH///////////////h////4D//////////////////////' +
160 'gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeP///4AAAAHzAAAAAAAAAAAAAAAAAAAAAH///////////////w////8B4/' +
161 '////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGH///4AAAADwQAAAAAAAAAAAAAAAAAAAAf////' +
162 '///////////wf///8AC/////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH///8AAAABwAAAAAA' +
163 'AAAAAAAAAAAAAAAf///////////////4f///8gCf///////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
164 'AADB///4AAAAAgAAAAAAAAAAAAAAAAAAAAA////////////////4P///+gOAf4f////////////////8AAAAAAAAAAAAAAAAAAAA' +
165 'AAAAAAAAAAAAAAAAAAAAAADg///4AAAAABgAAAAAAAAAAAAAAAAAAAA////////////////8P////geAAAP////////////////8' +
166 'MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwf//4AAAAAAgAAAAAAAAAAAAAAAAAAAB////////////////+H////w/A' +
167 'AAH////////////////wYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYP//4AAAAAAgAAAAAAAAAAAAAAAAAAAD/////' +
168 '///////////+B//////wAAD////////////////g4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIH//wAAAAAAAAAAAA' +
169 'AAAAAAAAAAAAAAD////////////////+B//////8AAA///////////////+A4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
170 'AAAAD//wAAAA/4AAAAAAAAAAAAAAAAAAAAH/////////////////A//////+AAAf//////n///////4AQAAAAAAAAAAAAAAAAAAA' +
171 'AAAAAAAAAAAAAAAAAAAAAAAAB//wAAABj/AAAAAAAAAAAAAAAAAAAAH/////////////////g///////AAAf//////j//////+AA' +
172 'QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//4AAAAAPgAAAAAAAAAAAAAAAAAAAP/////////////////w//////+' +
173 'AAAf/////8D//////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//4AAfAAB4AAAAAAAAAAAAAAAAAAAP/////' +
174 '////////////w//////8AAAP/////AD////+CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAB//8AB/AAA+AAAAA' +
175 'AAAAAAAAAAAAAAP/////////////////wf/////4AAAAP////AB////8GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
176 'AAAAB//8AB+AAAfwAAAAAAAAAAAAAAAAAAH/////////////////wP/////wAAAAP///+AB////4AAAAAAAAAAAAAAAAAAAAAAAA' +
177 'AAAAAAAAwAAAAAAAAAAAAAAAB//+AB+AAAAHYAAAAAAAAAAAAAAAAAH/////////////////wH/////gAAAAP///4AAf///wPAAA' +
178 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAA///AD+AAAAD/AAAAAAAAAAAAAAAAAH/////////////////4D/////w' +
179 'AAAAP///wAAf///geAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//gP+AAAAB/gAAAAAAAAAAAAAAAAD/////' +
180 '////////////4D/////AAAAAP///gAAP///weAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//7/8AAB4f0jgA' +
181 'AAAAAAAAAAAAAAD/////////////////8B////+AAAAAP///AAAH///wAAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
182 'AAAAAA////8AAAQAAAAAAAAAAAAAAAAAAAD/////////////////+A////4AAAAAP//+AAAH///4AAAAcAAAAAAAAAAAAAAAAAAA' +
183 'AAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAH//////////////////Af///4AAAAAH//4AAAH///8AAAA' +
184 'eAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///4AAAAAAAAAAAAAAAAAAAAAAAH//////////////////Af//+AA' +
185 'AAAAH//wAAAH3//+AAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//4AAAAAAAAAAAAAAAAAAAAAAAH/////' +
186 '/////////////AP//4AAAAAAH//gAAAPH///AAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADB/7/AAAAAAAA' +
187 'AAAAAAAAAAAAAAH//////////////////gf//wAAAAAAH/+AAAACH///gAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
188 'AAAAAAAAA///gAAAAAAAAAAAAAAAAAAAAAP//////////////////wP//gAAAAAAD/8AAAAAH///wAAA4AAAAAAAAAAAAAAAAAAA' +
189 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//gAAAAAAAAAAAAAAAAAAAAAf//////////////////8P/4AAAAAAAB/8AAAAAD///wAAA' +
190 'YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//wAAAAAAAAAAAAAAAAAAAAAP//////////////////+P/wAAA' +
191 'AAAAB/8AAAAAD///wAAAdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//gAAAAAAAAAAAAAAAAAAAAAP/////' +
192 '//////////////H+AAAAAAAAB/8AAAAAD///4AAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAN/gAAAAAAA' +
193 'AAAAAAAAAAAAAAP///////////////////vgAAAAAAAAA/+AAAAABz//4AAAYwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
194 'AAAAAAAAAAB/gAAAAAAAAAAAAAAAAAAAAAP///////////////////3AAAAAAAAAA/8AAAAABz//4AAAIQAAAAAAAAAAAAAAAAAA' +
195 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/gAAAgAAAAAAAAAAAAAAAAAH///////////////////4AAAAAAAAAA/8AAAAABwf/wAAA' +
196 'IGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAABmAAAAAAAAAAAAAAAAAH///////////////////wABAAA' +
197 'AAAAAf8AAAAABwP/wAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAAPHgAAAAAAAAAAAAAAAAB/////' +
198 '//////////////4A/AAAAAAAAP8AAAAABgP/gAAADGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAB//wAE' +
199 'AAAAAAAAAAAAAAA///////////////////8P/AAAAAAAAP8AAAAABgH/AAACCwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
200 'AAAAAAAAAAAPgAB/f/nsAAAAAAAAAAAAAAAf/////////////////////AAAAAAAAP4AAAAABgB8AAACAwAAAAAAAAAAAAAAAAAA' +
201 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF4AB/P//4AAAAAAAAAAAAAAAP////////////////////+AAAAAAAAHwAAAAABAA4AAAE' +
202 'BgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PD/P//8AAAAAAAAAAAAAAAH////////////////////+AAA' +
203 'AAAAAHzAAAAABAAwAAAIABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf5n////+AAAAAAAAAAAAAAAD////' +
204 '////////////////+AAAAAAAAHDAAAAADwAgAAAAAHgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHg//////' +
205 'gAAAAAAAAAAAAAAD////////////////////8AAAAAAAABHgAAAAD4AAAAAAA/gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
206 'AAAAAAAAAAAABx//////wAAAAAAAAAAAAAAD////////////////////8AAAAAAAAADgAAAAA4AAAAAAD/gAAAAAAAAAAAAAAAAA' +
207 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAg//////4AAAAAAAAAAAAAAB////////////////////4AAAAAAAAADgAAAAAYAAAAAA' +
208 'CPgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////8AAAAAAAAAAAAAAAf///////////////////4AAA' +
209 'AAAAAADgAAAAAOAAAABgAOgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////+AAAAAAAAAAAAAAAP///' +
210 '+H//////////////wAAAAAAAAADgAAAAAPAAAABgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////' +
211 '//AAAAAAAAAAAAAAD///gD//////////////wAAAAAAAAAAAAAAAAHgAAAD4ACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
212 'AAAAAAAAAAAAAAf///////4AAAAAAAAAAAAAB//+AD//////////////gAAAAAAAAAAAAAAB4PwAAAP8AAAAAAAAAAAAAAAAAAAA' +
213 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf///////8AAAAAAAAAAAAAA+AAAB//////////////AAAAAAAAAAAAAAAB8H4AAAf4' +
214 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf///////+AAAAAAAAAAAAAAAAAAAA/////////////AAAA' +
215 'AAAAAAAAAAAA+H4AAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////AAAAAAAAAAAAAAAAA' +
216 'AAAf///////////+AAAAAAAAAAAAAAAAfD4AAB/gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////' +
217 '///AAAAAAAAAAAAAAAAAAAAf///////////8AAAAAAAAAAAAAAAAPh4AAD/gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
218 'AAAAAAAAAAAAAA/////////AAAAAAAAAAAAAAAAAAAAf///////////4AAAAAAAAAAAAAAAAHx4AAf/gAAAAAAAAAAAAAAAAAAAA' +
219 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/////////gAAAAAAAAAAAAAAAAAAAf///////////wAAAAAAAAAAAAAAAAD48AAf/w' +
220 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/////////wAAAAAAAAAAAAAAAAAAAf///////////AAAAA' +
221 'AAAAAAAAAAAAB/MAE//wAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////wAAAAAAAAAAAAAAAA' +
222 'AAAf//////////+AAAAAAAAAAAAAAAAAA/kAP//4AEGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////' +
223 '///wAAAAAAAAAAAAAAAAAAAf//////////4AAAAAAAAAAAAAAAAAA/wAP//w/4GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
224 'AAAAAAAAAAAAAP/////////gAAAAAAAAAAAAAAAAAAAf//////////wAAAAAAAAAAAAAAAAAA/4AP//ggAEAAAAAAAAAAAAAAAAA' +
225 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////////+AAAAAAAAAAAAAAAAAAA///////////gAAAAAAAAAAAAAAAAAAf4AP//h' +
226 'AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////////+wAAAAAAAAAAAAAAAAAA///////////AAAAAA' +
227 'AAAAAAAAAAAAAP4AH//hhgCB8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////////+AAAAAAAAAAAAAAA' +
228 'AAA///////////AAAAAAAAAAAAAAAAAAAH+AH//B+AAD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////' +
229 '/////gAAAAAAAAAAAAAAAAA//////////+AAAAAAAAAAAAAAAAAAAH+AD/+B8AAA8BgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
230 'AAAAAAAAAAAAA////////////gAAAAAAAAAAAAAAAAAf/////////8AAAAAAAAAAAAAAAAAAAD/AD/+D8AAAGH4AAAAAAAAAAAAA' +
231 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////AAAAAAAAAAAAAAAAAP/////////wAAAAAAAAAAAAAAAAAAAB/wD/+D' +
232 'sAAA+P/gAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////+AAAAAAAAAAAAAAAAH/////////wAAAAAA' +
233 'AAAAAAAAAAAAAA/wAN+DuAz4f//4AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////////////gAAAAAAAAAAAA' +
234 'AAAD/////////wAAAAAAAAAAAAAAAAAAAAfwAAcBuAQEL///AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////' +
235 '///////wAAAAAAAAAAAAAAAB/////////gAAAAAAAAAAAAAAAAAAAAfwAAABnAAAA///wAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
236 'AAAAAAAAAAAAB//////////////4AAAAAAAAAAAAAAAB/////////AAAAAAAAAAAAAAAAAAAAAHwAAABlAAAAH//4AGAAAAAAAAA' +
237 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////////////gAAAAAAAAAAAAAAA/////////AAAAAAAAAAAAAAAAAAAAADwAAAB' +
238 'hAAAAB//8AMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////////////wAAAAAAAAAAAAAAA/////////AAAAAAA' +
239 'AAAAAAAAAAAAAABwAAAAAAAAAA//+H4EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///////////////wAAAAAAAAAAA' +
240 'AAAA/////////AAAAAAAAAAAAAAAAAAAAAAPAAAAAAAACAf//hgCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//////' +
241 '////////wAAAAAAAAAAAAAAAf////////gAAAAAAAAAAAAAAAAAAAAAfhwAAAAAACAf//AABQAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
242 'AAAAAAAAAAAAAH//////////////wAAAAAAAAAAAAAAAP////////gAAAAAAAAAAAAAAAAAAAAAH/8AAAAAAAAf//AAAIAAAAAAA' +
243 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//////////////wAAAAAAAAAAAAAAAP////////AAAAAAAAAAAAAAAAAAAAAAAn/gA' +
244 'AAAAAA//vgAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////wAAAAAAAAAAAAAAAP////////AAAAAAA' +
245 'AAAAAAAAAAAAAAAAAf4ABAAAABz+DwAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//////////////wAAAAAAAAAAA' +
246 'AAAAP////////gAAAAAAAAAAAAAAAAAAAAAAAAA88DwAAAB+B4AAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//////' +
247 '////////gAAAAAAAAAAAAAAAP////////gAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAEA+AAACAAAAAAAAAAAAAAAAAAAAAAAAAA' +
248 'AAAAAAAAAAAAAB//////////////gAAAAAAAAAAAAAAAP////////wAAAAAAAAAAAAAAAAAAAAAAAAABgcAAAAAAAfAAAMAAAAAA' +
249 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////AAAAAAAAAAAAAAAAH////////wAAAAAAAAAAAAAAAAAAAAAAAAAA' +
250 'AQAAAAAAAHgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////8AAAAAAAAAAAAAAAAH////////4AAAAAA' +
251 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf////////////8AAAAAAAAAAAA' +
252 'AAAAH////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////' +
253 '///////4AAAAAAAAAAAAAAAAH////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
254 'AAAAAAAAAAAAAAP////////////wAAAAAAAAAAAAAAAAH////////4AAIAAAAAAAAAAAAAAAAAAAAAAAAAAC/8AMAAAAAAAAAAAA' +
255 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////wAAAAAAAAAAAAAAAAP////////4AAIAAAAAAAAAAAAAAAAAAAAAAA' +
256 'AAAH/8AOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////////////gAAAAAAAAAAAAAAAAf////////4AAcAAA' +
257 'AAAAAAAAAAAAAAAAAAAAAAAH/4AeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////////////AAAAAAAAAAAAA' +
258 'AAAAf////////4AAcAAAAAAAAAAAAAAAAAAAAAAAAAAP/wAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////' +
259 '///////AAAAAAAAAAAAAAAAA/////////4AA8AAAAAAAAAAAAAAAAAAAAAAAAB8f/wAfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
260 'AAAAAAAAAAAAAAD////////////AAAAAAAAAAAAAAAAA/////////8AB8AAAAAAAAAAAAAAAAAAAAAAAAD8f/gAfwAAAAAAEAAAA' +
261 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////////////AAAAAAAAAAAAAAAAA/////////4AD+AAAAAAAAAAAAAAAAAAAAAAA' +
262 'AH///wAf4AAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf///////////AAAAAAAAAAAAAAAAA/////////4AH6AAA' +
263 'AAAAAAAAAAAAAAAAAAAAAH///8Af4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///////////AAAAAAAAAAAAA' +
264 'AAAB/////////wB/8AAAAAAAAAAAAAAAAAAAAAAAAX///+Af4AAAAAACAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////' +
265 '///////AAAAAAAAAAAAAAAAB/////////gB/8AAAAAAAAAAAAAAAAAAAAAAABf////A/4AAAAAAAAAAGAAAAAAAAAAAAAAAAAAAA' +
266 'AAAAAAAAAAAAAAAAf//////////AAAAAAAAAAAAAAAAB////////8AD/4AAAAAAAAAAAAAAAAAAAAAAAB/////w/8AAAAAAAAAAA' +
267 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//////////AAAAAAAAAAAAAAAAA////////wAD/4AAAAAAAAAAAAAAAAAAAAAAA' +
268 'B///////8AAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/////////+AAAAAAAAAAAAAAAAA////////gAD/4AAA' +
269 'AAAAAAAAAAAAAAAAAAAAD///////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/////////+AAAAAAAAAAAAA' +
270 'AAAAf///////AAD/wAAAAAAAAAAAAAAAAAAAAAAAH///////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///' +
271 '//////+AAAAAAAAAAAAAAAAAf//////+AAB/wAAAAAAAAAAAAAAAAAAAAAAAH////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
272 'AAAAAAAAAAAAAAAAD/////////8AAAAAAAAAAAAAAAAAP//////8AAB/wAAAAAAAAAAAAAAAAAAAAAAAf////////wAAAAAAAAAA' +
273 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////4AAAAAAAAAAAAAAAAAP//////4AAB/gAAAAAAAAAAAAAAAAAAAAAAD' +
274 '/////////4AAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////4AAAAAAAAAAAAAAAAAH//////8AAD/gAAA' +
275 'AAAAAAAAAAAAAAAAAAB//////////8AAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////wAAAAAAAAAAAAA' +
276 'AAAAH//////8AAH/gAAAAAAAAAAAAAAAAAAAAAH//////////+AAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///' +
277 '//////wAAAAAAAAAAAAAAAAAD//////+AAH/AAAAAAAAAAAAAAAAAAAAAAP//////////+AAAAAYAAAAAAAAAAAAAAAAAAAAAAAA' +
278 'AAAAAAAAAAAAAAAAD/////////gAAAAAAAAAAAAAAAAAB//////+AAH/AAAAAAAAAAAAAAAAAAAAAAf//////////+AAAAAEAAAA' +
279 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/////////AAAAAAAAAAAAAAAAAAB//////+AAH/AAAAAAAAAAAAAAAAAAAAAA//' +
280 '//////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////////wAAAAAAAAAAAAAAAAAAB//////+AAH+AAAA' +
281 'AAAAAAAAAAAAAAAAAA////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///////+AAAAAAAAAAAAAAA' +
282 'AAAAB//////+AAH+AAAAAAAAAAAAAAAAAAAAAB////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///' +
283 '////4AAAAAAAAAAAAAAAAAAAB//////+AAH+AAAAAAAAAAAAAAAAAAAAAB////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
284 'AAAAAAAAAAAAAAAAH///////gAAAAAAAAAAAAAAAAAAAB//////4AAD8AAAAAAAAAAAAAAAAAAAAAB////////////+AAAAAAAAA' +
285 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///////AAAAAAAAAAAAAAAAAAAAB//////gAABwAAAAAAAAAAAAAAAAAAAAAA//' +
286 '///////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//////+AAAAAAAAAAAAAAAAAAAAA/////+AAAAAAAAA' +
287 'AAAAAAAAAAAAAAAAAA/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////+AAAAAAAAAAAAAAAA' +
288 'AAAAA//////AAAAAAAAAAAAAAAAAAAAAAAAAAB/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///' +
289 '///+AAAAAAAAAAAAAAAAAAAAA//////AAAAAAAAAAAAAAAAAAAAAAAAAAB/////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
290 'AAAAAAAAAAAAAAAAP//////+AAAAAAAAAAAAAAAAAAAAAf////+AAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////AAAAAAAAA' +
291 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////+AAAAAAAAAAAAAAAAAAAAAf////+AAAAAAAAAAAAAAAAAAAAAAAAAAA//' +
292 '///////////gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//////+AAAAAAAAAAAAAAAAAAAAAP////+AAAAAAAAA' +
293 'AAAAAAAAAAAAAAAAAAf////////////gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//////8AAAAAAAAAAAAAAAA' +
294 'AAAAAH////8AAAAAAAAAAAAAAAAAAAAAAAAAAAf////////////gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf///' +
295 '///4AAAAAAAAAAAAAAAAAAAAAH////4AAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////gAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
296 'AAAAAAAAAAAAAAAAf//////wAAAAAAAAAAAAAAAAAAAAAD////wAAAAAAAAAAAAAAAAAAAAAAAAAAAP////////////gAAAAAAAA' +
297 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//////wAAAAAAAAAAAAAAAAAAAAAB////gAAAAAAAAAAAAAAAAAAAAAAAAAAAP/' +
298 '///////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//////gAAAAAAAAAAAAAAAAAAAAAB////gAAAAAAAAA' +
299 'AAAAAAAAAAAAAAAAAAP////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//////AAAAAAAAAAAAAAAAA' +
300 'AAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAH////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf///' +
301 '//+AAAAAAAAAAAAAAAAAAAAAAA///+AAAAAAAAAAAAAAAAAAAAAAAAAAAAH////A///////AAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
302 'AAAAAAAAAAAAAAAAf/////4AAAAAAAAAAAAAAAAAAAAAAA///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAD///QAH/////+AAAAAAAAA' +
303 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////4AAAAAAAAAAAAAAAAAAAAAAA///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/' +
304 '/8AAD/////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////wAAAAAAAAAAAAAAAAAAAAAAA///gAAAAAAAAAA' +
305 'AAAAAAAAAAAAAAAAAAH//wAAB+////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////gAAAAAAAAAAAAAAAAA' +
306 'AAAAAA//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//wAAA8////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////' +
307 '7/gAAAAAAAAAAAAAAAAAAAAAAAfgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP+AAAAA5////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
308 'AAAAAAAAAAAAAAAA////8eAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4AAAAARf///wAAAAAACAA' +
309 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
310 'AAAAAAf///gAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
311 'AAAAAAAAAAAAAAAAAAAAAAAAAAP///gAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AAAAAAAAAAAAAAAAAAA' +
312 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///AAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD////' +
313 '/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///AAAAAAAAQAAAAAAAAAAAAAAAAAAAAA' +
314 'AAAAAAAAAAAAAAAH/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///AAAAAAAAcA' +
315 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
316 'AAAAAAB//+AAAAAAAAeIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
317 'AAAAAAAAAAAAAAAAAAAAAAAAAAAfvgAAAAAAAAf4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////gAAAAAAAAAAAAAAAAAAA' +
318 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///w' +
319 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAA' +
320 'AAAAAAAAAAAAAAAH///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfA' +
321 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
322 'AAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
323 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAEOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//wAAAAAAAAAAAAAAAAAAAAA' +
324 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4AAAAAAANcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//wA' +
325 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4AAAAAAAfAAAAAAAAAAAAAAAAAAAAAA' +
326 'AAAAAAAAAAAAAAAL//6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAAAAAAAfAA' +
327 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
328 'AAAAAAAAHwAAAAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL//wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
329 'AAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAAAAAAAAAAAAAAAAAAA' +
330 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAT//gA' +
331 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/gAAAAAAAAAAAAAAAAAAAAAA' +
332 'AAAAAAAAAAAAAAAf//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAA' +
333 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
334 'AAAAAAAAAAAAAAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
335 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//4AAAAAAAAAAAAAAAAAAAAAA' +
336 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AA' +
337 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
338 'AAAAAAAAAAAAAAAf//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
339 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
340 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB///AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
341 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//8AAAAAAAAAAAAAAAAAAAAAA' +
342 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//4AA' +
343 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
344 'AAAAAAAAAAAAAAB//4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
345 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
346 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
347 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//AAAIAAAAAAAAAAAAAAAAAAA' +
348 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//AAD' +
349 '8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
350 'AAAAAAAAAAAAAAA//gACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
351 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
352 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL3wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
353 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG3wAAAAAAAAAAAAAAAAAAAAAA' +
354 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/8AA' +
355 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
356 'AAAAAAAAAAAAAAAAf/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
357 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
358 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
359 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
360 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
361 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
362 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
363 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
364 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
365 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
366 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
367 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
368 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
369 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
370 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
371 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
372 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
373 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
374 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
375 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
376 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
377 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAAAAAAAAAAAAAAAAAAA' +
378 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH' +
379 'QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
380 'AAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
381 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
382 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
383 'AAAAAAAAAAAAAAYAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/gAAAAAAAAAAAAAAAAAAAA' +
384 'AAAAAAAAAAAAAAAAAP/AAAAAAAAAAAAAAB/AAfgAAAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8A' +
385 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//gAAAAAAAAAYAAAH/wD//AD/+//4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
386 'AAAAAAAAAAAAAAAAAHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//4AAAAAAAEf//+f//////+////////8AAAAAAAAAAA' +
387 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////wfAAAAA/////////////' +
388 '////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////' +
389 '///AAAH//////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAA' +
390 'AAAAAAAAAAAcAB/////////gAAf///////////////////////4MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAef/A' +
391 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/4P/////////gAA//////////////////////////gAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
392 'AAAAAAAAAAAAAAAAef/gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/+//////////gAf///////////////////////////gAAAAAA' +
393 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAff/gAAAAAAAAAAAAAAAAAAAAAAPwff/gAD////////////8AH///////////////' +
394 '/////////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v/wAAAAAAAAAAAAAAAAAAAAAH/5////////////////' +
395 '///AP/////////////////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/n/4AAAAAAAAAAAAAAAAAHge' +
396 'P/////////////////////+A////////////////////////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAA//j/4' +
397 'AAAAAAAAAAAAAAAAP/////////////////////////8B/////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAA' +
398 'AAAAAA/+AAAAAAAc+D/8AAAAAAAAAAAAAAAA///////////////////////////v/////////////////////////////////AAA' +
399 'AAAAAAAAAAAAAAAAAAAAAAAAAEAAAAQAAAAAAP/8AAAAAAAAAAAAAAAH////////////////////////////////////////////' +
400 '////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAH/wBw/4AQAAf//+AAAAAAAAAAAAAAB/////////////////////////' +
401 '////////////////////////////////////8AAAAAAAAAAAAAAAAAAAAAYfwAAAAD//////9/4f///8AAAAAAAAAAAAAAH/////' +
402 '////////////////////////////////////////////////////////gAAAAAAAAAAAAAAAAAAAAAAAP/wAAAP////////////4' +
403 'AAAAAAAAAAAAAAB////////////////////////////////////////////////////////////8AAAAAAAAAAAAAAAAAAf/////' +
404 '///wAAH///////////+AAAAAAAAAAAAAAAH////////////////////////////////////////////////////////////gAAAA' +
405 'AAAAAAAAAAAAH/////////////3///////////4AAAAAAAAAAAAAAA//////////////////////////////////////////////' +
406 '//////////////+AAAAAAAAAAAAAAAAf/////////////////////////8AAAAAAAAAAAAAAAf//////////////////////////' +
407 '//////////////////////////////////8AAAAAAAAAAAAAAAAP////////////////////////8AAAAAAAAAAAAAAA////////' +
408 '//////////////////////////////////////////////////////4AAAAAAAAAAAAAAAD///////////////////////wAAAAA' +
409 'AAAAAAAAAAP///////////////////////////////////////////////////////////////4AAAAAAAAAAAAc+B//////////' +
410 '//////////////+AAAAAAAAAAAAAAB////////////////////////////////////////////////////////////////8AAAAA' +
411 'AAAAAAAP///////////////////////////wAAAAAAAAAAAAA///////////////////////////////////////////////////' +
412 '//////////////8AAAAAAAAAAAAH//////////////////////////wAAAAAAAAD/gAAf///////////////////////////////' +
413 '///////////////////////////////////gAAAAAAAAAH8A//////////////////////////AAAAAAAAAP/4AA////////////' +
414 '///////////////////////////////////////////////////////8AAAAAAAAAB+AAP////////////////////////AAAAAA' +
415 'AAAf/4AA/////////////////////////////////////////////////////////////////+AAAAAAAAAAAAAAAAB/////////' +
416 '//////////////4AAAACAAB//4AAAAf//////////////////////////////////////////////////////////////8AAAAAA' +
417 'AAAAAAAAAAAf//////////////////////+AAAAHgA///gAAAAH/////////////////////////////////////////////////' +
418 '/////////////4AAAAAAAAAAAAAAAAB/////////////////////////wAP/AAf/AAAAAP//////////////////////////////' +
419 '/////////////////////////////////wAAAAAAAAAAAAAA/8///////////////////////////gAAAAAAAAAH////////////' +
420 '/////////////////////////////////////////////////////8AAAAAAAAAAAAAAH//////////////////////////////A' +
421 'AAAP4B////////////////////////////////////////////////////////////////////AAAAAAAAAAAAAAA///////////' +
422 '///////////////////+AAf///////////////////////////////////////////////////////////////////////wAAAAA' +
423 'AAAAAAAAA///////////////////////////////wf//////////////////////////////////////////////////////////' +
424 '///////////////gAAAAAAAAAAAAB///////////////////////////////////////////////////////////////////////' +
425 '////////////////////////////////////gAAAAAAAAAAAA///////////////////////////////////////////////////' +
426 '////////////////////////////////////////////////////////4AAAIYf+AAAAAA//////////////////////////////' +
427 '/////////////////////////////////////////////////////////////////////////////8OAAAAAA///////wAAAAAAA' +
428 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
429 'AAAAAAD8AP/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
430 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
431 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
432 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
433 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
434 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
435 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
436 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
437 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
438 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
439 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
440 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
441)
442
443/** Whether the point at `lat`, `lon` (degrees) is land. */
444export function isLand(lat: number, lon: number): boolean {
445 let x = Math.floor(((lon + 180) / 360) * LAND_WIDTH)
446 let y = Math.floor(((90 - lat) / 180) * LAND_HEIGHT)
447 x = ((x % LAND_WIDTH) + LAND_WIDTH) % LAND_WIDTH
448 y = Math.max(0, Math.min(LAND_HEIGHT - 1, y))
449 const i = y * LAND_WIDTH + x
450 return (MASK[i >> 3]! & (0x80 >> (i & 7))) !== 0
451}
452