SLOPSHOPPER

orbit

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…

newpaneguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-02jamubc/toolbox/plugins/orbit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orbit
│ ┃ Orbit ✕ › fix the failing auth test and add an audit log call │ ┃ ● session all sessions │ ◀ ⌂ ▶ │ export clea │ ┃ ▣ the globe ⏺ Read(src/auth.ts) │ ┃ this session talked to 0 countries · 0 ⎿ Read 6 lines │ ┃ orgs · 0 hosts · ↓190b ↑0b ⏺ Update(src/auth.ts) │ ┃ ━ out ━ in ╌ blocked ● you ⎿ Added 2 lines, removed 1 line │ ┃ No home yet: set it in /config → Where you ⏺ Bash(bun test) │ ┃ are, or /orbit locate (one public-IP ⎿ 3 pass, 1 fail │ ┃ lookup). │ ┃ Places need Node.js for the geo helper: ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ install node, or set its path in /config. │ ┃ tools ✓ · sockets ✗ · bytes ✗ · geo ✗ · ✻ Worked for 42s · done 4:20 PM │ ┃ proxy off │ ┃ 22:27:54 ⚙ Bash → bun test › /orbit │ ┃ 22:27:54 ⚙ Bash → git status --porce ⎿ orbit: Orbit opened. │ ┃ 22:27:54 ⚙ Bash → rm -rf build && git │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Orbit
● session all sessions │ ◀ ⌂ ▶ │ export clear │ enforcing: d ▣ the globe this session talked to 0 countries · 0 orgs · 0 hosts · ↓190b ↑0b ━ out ━ in ╌ blocked ● you No home yet: set it in /config → Where you are, or /orbit locate (one public-IP lookup). Places need Node.js for the geo helper: install node, or set its path in /config. tools ✓ · sockets ✗ · bytes ✗ · geo ✗ · proxy off 22:27:54 ⚙ Bash → bun test ↓159b ↑0b 22:27:54 ⚙ Bash → git status --porce ↓31b ↑0b 22:27:54 ⚙ Bash → rm -rf build && git push -
Pane · orbit-ask
Nothing to ask.
README

orbit

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.

Install

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.

Usage

CommandWhat it does
/orbitOpens or closes the pane: the globe, the badge, the legend and the live log.
/orbit all / /orbit sessionObserve every Claude session on this machine (each its own color, with a legend) or this one only.
Click a log rowHighlights 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 rulesLists 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 locateSets 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 checkWhich layers work here, and why not.
/config → Show hosts and countries at the bottom of the terminalShows orbit · 6 hosts · 3 countries on the status line.

What it sees

Three layers feed one trace, in the order they are worth having:

  1. Tool layer. A 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.
  2. Process layer. Every two seconds it reads the session's process tree (Claude Code, its shells, MCP servers, subagents) and the sockets they hold: 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.
  3. Proxy layer (optional). A local HTTP(S) proxy for the commands and MCP servers the session starts after it is on, by setting 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.

Places

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).

Compatibility

Where you run Claude CodeGlobeLayers
kitty, GhosttyReal pixels (kitty graphics protocol), 4 frames a second. Untested so far.All
Any other terminal, tmux, sshColored half-blocks, two pixels per character, 10 frames a second.All
Claude desktop app, VS Code extension, mobileThe trace and the badge; no globe.Tool layer; the rest needs the terminal
LayermacOSLinuxElevated permissions
ToolYesYesNone
Process: socketslsof -i (ships with macOS)ss (iproute2), or /procNone for your own processes. Other users' processes need root, so observe-all sees your sessions only.
Process: bytesnettop (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.
ProxyNode.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.
GeoNode.js 18+ for the helperNode.js 18+None. One download you ask for.
BlockingTool layer onlyTool layer onlyNone. 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.

  • Needs nothing for the tool and process layers on macOS. On Linux, ss from iproute2 (nearly always there). Node.js for the geo helper and the proxy.
  • One Claude Code session is found by walking ps from the shell it runs in; if that fails /orbit check says so and only the tool layer records.
  • Processes a tool started and left running (a dev server) stay in the tree and keep tracing until they exit.
  • Sockets are polled every two seconds, so a connection that lives less than that can be missed by the process layer. The tool layer still records what caused it, and the proxy layer sees every connection of the children it covers.
  • Linux byte counters come from 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.
  • A hostname is known from the tool layer and the proxy; the process layer sees addresses, and orbit does no reverse DNS (that would be traffic of its own). Matching by time is a guess when several calls run at once.
  • Observe-all mode labels a session by its folder and terminal, not its title: Claude Code keeps no title where a process can be matched to it from outside.
  • The proxy cannot see the Claude Code process itself, nor children that ignore 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.
  • The globe is a half-degree land mask from Natural Earth outlines; at pane sizes coastlines are rough, and in image mode the same mask is drawn at pixel size.
  • Real-pixel mode is untested on a live kitty or Ghostty; it falls back to blocks after three refused frames.
  • 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.
  • Read-only, apart from blocking what you asked it to block: it never starts, stops or cuts anything.
  • It reads process and socket tables with ps, lsof, ss, nettop or /proc, for your own processes.
  • No request per connection. The only requests it ever makes are the ones you ask for: /orbit geodb (db-ip.com) and /orbit locate (api.ipify.org, or ipinfo.io without a database).
  • The proxy sees hostnames, addresses and byte counts; it does not decrypt TLS and keeps no request bodies.
  • The trace is written to ~/.claude/orbit/traces (off with /config → Write the trace to disk every session) and rules to the plugin's store. Nothing leaves your machine.
Source 13 files
hooks/register.tsx 987 lines
1// 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}
987
hooks/canvas.ts 130 lines
1// 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')}`
130
hooks/geo.ts 234 lines
1// 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 || '??'
234
hooks/globe.ts 232 lines
1// 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}
232
hooks/model.ts 263 lines
1// 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}
263
hooks/pane.tsx 306 lines
1// 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)
306
hooks/png.ts 199 lines
1// 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}
199
hooks/procs.ts 268 lines
1// 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'
268
hooks/rules.ts 95 lines
1// 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}
95
hooks/tools.ts 73 lines
1// 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__')
73
hooks/trace.ts 247 lines
1// 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}
247
hooks/land.ts 452 lines
1// 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