the-invisible-billion-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sudogetshivam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,371 @@
1
+ # Pollen
2
+
3
+ > Fully offline, zero-internet, peer-to-peer messenger.
4
+ > Messages spread like a virus through human movement. No servers. No cloud. Just people carrying bytes.
5
+
6
+ You know how the plague spread across medieval Europe? Person to person, city to city, through nothing but human contact? Yeah, Pollen does that — but with your encrypted messages, and hopefully with less death involved.
7
+
8
+ ---
9
+
10
+ ## Table of Contents
11
+
12
+ - [Why Does This Exist?](#why-does-this-exist)
13
+ - [How It Works](#how-it-works)
14
+ - [Prerequisites](#prerequisites)
15
+ - [Installation](#installation)
16
+ - [Quick Start](#quick-start)
17
+ - [Commands](#commands)
18
+ - [Architecture](#architecture)
19
+ - [Encryption](#encryption)
20
+ - [Data Storage](#data-storage)
21
+ - [Configuration & Limits](#configuration--limits)
22
+ - [FAQ](#faq)
23
+ - [Contributing](#contributing)
24
+ - [License](#license)
25
+
26
+ ---
27
+
28
+ ## Why Does This Exist?
29
+
30
+ Because sometimes the internet just... isn't there. Natural disasters. Authoritarian shutdowns. That one corner of your college campus where WiFi goes to die. Remote villages. Underground bunkers (no judgment).
31
+
32
+ Pollen implements **Epidemic Routing**, a real protocol from Delay-Tolerant Networking (DTN) research — the same class of protocols NASA uses to communicate with spacecraft millions of miles away. If it's good enough for Mars, it's good enough for your group chat.
33
+
34
+ The idea is simple: your message copies itself to every Pollen user nearby. They walk away, go to a coffee shop, an airport, another city — and their device spreads it further. Eventually, someone physically encounters the recipient, and the message delivers itself. All encrypted. All automatic. Zero internet at any point.
35
+
36
+ **Think of it as WhatsApp if WhatsApp had to survive the apocalypse.**
37
+
38
+ ---
39
+
40
+ ## How It Works
41
+
42
+ ```
43
+ You Strangers Recipient
44
+ | | |
45
+ |-- encrypt & store ----->| |
46
+ | (WiFi/hotspot) | |
47
+ | |-- carry physically -->|
48
+ | | (walk, drive, fly) |
49
+ | | |
50
+ | |-- deliver on LAN ---->|
51
+ | | |-- decrypt
52
+ | | |
53
+ |<------------- ACK (travels back the same way) -|
54
+ ```
55
+
56
+ 1. You send a message. It gets **encrypted** with the recipient's public key and stored locally.
57
+ 2. Your device broadcasts it over **local WiFi/hotspot** to every nearby Pollen user.
58
+ 3. Those users don't even know they're carrying it. Their devices just... have it. Like a cold.
59
+ 4. When they move to a new network, the message spreads to everyone there too.
60
+ 5. The moment a carrier lands on the same network as the recipient, the message delivers automatically.
61
+ 6. An **ACK** receipt travels back to you the exact same way. Epidemically.
62
+
63
+ No central server. No internet backbone. Just mesh networking powered by human commutes.
64
+
65
+ ---
66
+
67
+ ## Prerequisites
68
+
69
+ - **Node.js >= 18** ([download](https://nodejs.org))
70
+ - A **C++ build toolchain** (required by `better-sqlite3`, the native SQLite addon):
71
+
72
+ | OS | Install |
73
+ |---|---|
74
+ | **Windows** | [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) + Python 3 |
75
+ | **macOS** | `xcode-select --install` |
76
+ | **Linux** | `sudo apt install build-essential python3` |
77
+
78
+ Verify your setup:
79
+
80
+ ```bash
81
+ node -v # should print v18.x.x or higher
82
+ npm -v # should print a version number
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Installation
88
+
89
+ ### From npm (recommended)
90
+
91
+ ```bash
92
+ npm install -g pollen-transmit
93
+ ```
94
+
95
+ > On macOS/Linux, you may need `sudo npm install -g pollen-transmit` if you get permission errors.
96
+
97
+ ### From source
98
+
99
+ ```bash
100
+ git clone https://github.com/sudogetshivam/pollen-transmit.git
101
+ cd pollen-transmit
102
+ npm install
103
+ npm link # makes the 'pollen' command available globally
104
+ ```
105
+
106
+ Verify installation:
107
+
108
+ ```bash
109
+ pollen --version
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Quick Start
115
+
116
+ **1. Start the daemon**
117
+
118
+ ```bash
119
+ pollen start
120
+ ```
121
+
122
+ On first run, Pollen will ask for a username and generate your identity (e.g., `alice@f7a2`) along with an RSA-2048 keypair. Think of the identity as your Pollen phone number — share it with friends so they can message you.
123
+
124
+ > Keep this terminal window open. The daemon runs in the background, silently discovering peers and relaying messages. Open a new terminal for commands.
125
+
126
+ **2. Find people nearby**
127
+
128
+ ```bash
129
+ pollen scan
130
+ ```
131
+
132
+ Lists all Pollen users discovered on your current WiFi or hotspot. If your friend started their daemon nearby, they'll show up here.
133
+
134
+ **3. Send a message**
135
+
136
+ ```bash
137
+ pollen send bob@b7d9 "Internet is overrated anyway"
138
+ ```
139
+
140
+ This encrypts and queues the message. If Bob is on your network, it delivers instantly. If not, it starts spreading epidemically through everyone around you. The command returns a **Message ID** for tracking.
141
+
142
+ **4. Check delivery status**
143
+
144
+ ```bash
145
+ pollen status <message-id>
146
+ ```
147
+
148
+ Shows one of three states:
149
+ - **Undelivered** — sitting on your device, waiting for carriers
150
+ - **In Transit** — carriers are spreading it across networks
151
+ - **Delivered** — the recipient got it, and an ACK is on its way back to you
152
+
153
+ **5. Read incoming messages**
154
+
155
+ Messages arrive automatically while your daemon runs. Check your inbox:
156
+
157
+ ```bash
158
+ # macOS / Linux
159
+ cat ~/.pollen/inbox.log
160
+
161
+ # Windows
162
+ type %USERPROFILE%\.pollen\inbox.log
163
+ ```
164
+
165
+ **6. Stop the daemon**
166
+
167
+ ```bash
168
+ pollen stop
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Commands
174
+
175
+ | Command | Description |
176
+ |---|---|
177
+ | `pollen start` | Start the background daemon. First run creates your identity and keys. |
178
+ | `pollen stop` | Shut down the daemon gracefully. |
179
+ | `pollen scan` | Discover Pollen peers on your current network. |
180
+ | `pollen send <identity> "<message>"` | Send an end-to-end encrypted message. |
181
+ | `pollen status <messageId>` | Track a message's delivery journey. |
182
+ | `pollen sync` | Force an immediate epidemic exchange with all known peers. |
183
+ | `pollen file <identity> <filepath>` | Send a file via epidemic routing. |
184
+ | `pollen --version` | Print the installed version. |
185
+ | `pollen --help` | Show all available commands. |
186
+
187
+ ---
188
+
189
+ ## Architecture
190
+
191
+ ```
192
+ pollen-transmit/
193
+ ├── bin/
194
+ │ └── pollen.js # CLI entry point (Commander.js)
195
+ └── src/
196
+ ├── cli/
197
+ │ ├── ipc.js # CLI <-> Daemon communication (IPC client)
198
+ │ └── commands/
199
+ │ ├── start.js # Spawn daemon, create identity on first run
200
+ │ ├── stop.js # Send shutdown signal
201
+ │ ├── scan.js # Query daemon for discovered peers
202
+ │ ├── send.js # Encrypt and queue a message
203
+ │ ├── status.js # Query message delivery status
204
+ │ ├── sync.js # Trigger manual epidemic exchange
205
+ │ └── file.js # File transfer command
206
+ ├── identity/
207
+ │ └── index.js # Username + shortID generation (e.g., alice@f7a2)
208
+ ├── crypto/
209
+ │ ├── keys.js # RSA-2048 keypair generation and storage
210
+ │ └── encrypt.js # Hybrid AES-256-GCM + RSA-OAEP encryption
211
+ ├── db/
212
+ │ ├── index.js # SQLite connection and schema setup
213
+ │ ├── messages.js # Message CRUD + peer key/IP storage
214
+ │ └── cleanup.js # TTL expiry and stale data cleanup
215
+ └── daemon/
216
+ ├── index.js # Main daemon process (IPC server, event loop)
217
+ ├── network.js # IP polling and network change detection
218
+ ├── udp.js # UDP broadcast for peer discovery
219
+ ├── tcp.js # TCP for reliable message transfer
220
+ └── epidemic.js # Epidemic routing engine + ACK generation
221
+ ```
222
+
223
+ ### How the pieces fit together
224
+
225
+ The CLI is just a thin client. It does **zero networking**. Every command serializes a request over IPC (Unix socket on Linux/macOS, named pipe on Windows) to the daemon, which does all the heavy lifting.
226
+
227
+ The daemon runs detached in the background and manages:
228
+
229
+ - **UDP broadcast** every 15 seconds for peer discovery — announces your identity and public key
230
+ - **TCP connections** for reliable message transfer between peers
231
+ - **Epidemic forwarding** — continuously spreads pending messages to all known peers
232
+ - **Network change detection** — when you switch WiFi, triggers a fresh peer scan and epidemic exchange
233
+ - **Startup delivery sweep** — on boot, retries all pending messages against known peers
234
+ - **Stale peer cleanup** — removes peers not seen in 3+ minutes
235
+
236
+ ### Key Design Decisions
237
+
238
+ | Decision | Why |
239
+ |---|---|
240
+ | IPC via Unix socket / named pipe | No port conflicts, no firewall headaches |
241
+ | Hybrid AES-256-GCM + RSA-OAEP | RSA alone caps at ~190 bytes. Hybrid handles messages of any length. |
242
+ | Daemon spawned detached | CLI exits immediately. Daemon lives independently. Your terminal is free. |
243
+ | `windowsHide: true` | No random console window popping up on Windows (you're welcome) |
244
+ | Max 20 hops | Prevents messages from circulating forever. If it hasn't arrived in 20 hops, it probably won't. |
245
+ | 7-day TTL | Messages expire after a week. Keeps carrier devices from becoming digital hoarders. |
246
+ | TCP with UDP fallback | TCP for reliability, UDP when firewalls get in the way |
247
+
248
+ ---
249
+
250
+ ## Encryption
251
+
252
+ Every message is end-to-end encrypted. Relay nodes (the strangers carrying your messages) can see *who* the message is for, but **cannot read the contents**. Period.
253
+
254
+ Here's what happens under the hood:
255
+
256
+ ```
257
+ Sender Relay / Carrier Recipient
258
+ | | |
259
+ | 1. Generate random AES-256 key | |
260
+ | 2. Encrypt message with AES-GCM | |
261
+ | 3. Encrypt AES key with | |
262
+ | recipient's RSA public key | |
263
+ | | |
264
+ |--- encrypted blob (opaque) ------->| |
265
+ | (can see destination, | |
266
+ | cannot read content) |--- encrypted blob ----->|
267
+ | | |
268
+ | | 4. Decrypt AES key |
269
+ | | with private key |
270
+ | | 5. Decrypt message |
271
+ | | with AES key |
272
+ | | |
273
+ |<============ ACK (epidemic route back) ======================|
274
+ ```
275
+
276
+ - **AES-256-GCM** handles the actual message encryption (fast, no size limit, authenticated)
277
+ - **RSA-2048 OAEP** wraps the AES key so only the recipient can unwrap it
278
+ - A fresh AES key is generated **per message** — compromising one key reveals nothing about any other message
279
+
280
+ Even if every single relay node was actively malicious, they'd just be carrying encrypted gibberish. The only device that can read the message is the one holding the recipient's private key.
281
+
282
+ ---
283
+
284
+ ## Data Storage
285
+
286
+ Everything lives in `~/.pollen/`:
287
+
288
+ | File/Directory | Purpose |
289
+ |---|---|
290
+ | `identity.json` | Your username and short ID |
291
+ | `keys/public.pem` | Your RSA public key (shared with peers) |
292
+ | `keys/private.pem` | Your RSA private key (never leaves your machine, mode `0600`) |
293
+ | `pollen.db` | SQLite database: messages, peer keys, peer IPs |
294
+ | `inbox.log` | Received messages log |
295
+ | `daemon.pid` | PID of the running daemon process |
296
+ | `daemon.log` | Daemon runtime logs |
297
+
298
+ > Your private key is stored with restricted file permissions. Don't share it. Don't back it up to Google Drive. Don't tattoo it on your arm. Basically, just leave it alone.
299
+
300
+ ---
301
+
302
+ ## Configuration & Limits
303
+
304
+ | Parameter | Value | Notes |
305
+ |---|---|---|
306
+ | Max hop count | 20 | Message is dropped after 20 relays |
307
+ | Default TTL | 7 days | Messages expire and get cleaned up |
308
+ | UDP heartbeat | Every 15s | How often your device announces itself |
309
+ | Stale peer timeout | 3 minutes | Peers not seen in 3 min are removed |
310
+ | RSA key size | 2048 bits | Generated once on first `pollen start` |
311
+ | AES key size | 256 bits | Fresh random key per message |
312
+ | AES mode | GCM | Authenticated encryption with 96-bit IV |
313
+
314
+ These are sensible defaults. The hop count prevents messages from bouncing around the network forever (imagine a message that just won't take the hint). The 7-day TTL keeps your database from slowly becoming a digital landfill.
315
+
316
+ ---
317
+
318
+ ## FAQ
319
+
320
+ **Q: Do both users need to be online at the same time?**
321
+ No. That's the entire point. You send a message, it starts spreading. The recipient can come online days later in a completely different city. As long as the epidemic chain reaches them within the TTL window, they'll get it.
322
+
323
+ **Q: What if nobody is running Pollen near me?**
324
+ Then your message waits patiently on your device. The moment someone running Pollen connects to your network, it copies over. Patience is a virtue, and your message has plenty of it.
325
+
326
+ **Q: Is this actually secure?**
327
+ Yes. AES-256-GCM + RSA-2048 is the same class of encryption used by banks, governments, and that one friend who encrypts their grocery lists. Relay nodes carry your messages but literally cannot read them.
328
+
329
+ **Q: How do I share my identity with someone?**
330
+ The old-fashioned way. Tell them in person, text it, write it on a napkin, send it via carrier pigeon — whatever works. Your identity (`username@shortid`) is not a secret; it's like a phone number.
331
+
332
+ **Q: Can I use this over a mobile hotspot?**
333
+ Absolutely. Any local network works — WiFi, hotspot, even a direct device-to-device connection. Pollen doesn't care how you're connected, just that you are.
334
+
335
+ **Q: Why SQLite?**
336
+ Because it's embedded, requires zero configuration, handles concurrent reads well, and the binary size is smaller than your average npm dependency list. Plus, it won't ghost you with connection pool errors at 3 AM.
337
+
338
+ **Q: What happens if two people send messages at the same time?**
339
+ They both work fine. Messages have unique UUIDs and the epidemic protocol handles deduplication. If a device already has a copy of a message, it rejects the duplicate. No echoes, no infinite loops.
340
+
341
+ **Q: Is this a blockchain?**
342
+ No. Please stop asking. There is no ledger, no mining, no tokens, no gas fees. Just encrypted messages being carried by humans. Like pigeons, but with RSA keys.
343
+
344
+ ---
345
+
346
+ ## Contributing
347
+
348
+ Contributions are welcome. The codebase is intentionally straightforward — no framework soup, no transpilers, no build steps beyond `npm install`. Just Node.js doing what Node.js does.
349
+
350
+ 1. Fork the repository
351
+ 2. Create your feature branch: `git checkout -b feature/your-feature`
352
+ 3. Make your changes
353
+ 4. Test locally by running `pollen start` on two machines on the same network
354
+ 5. Commit: `git commit -m "Add your feature"`
355
+ 6. Push: `git push origin feature/your-feature`
356
+ 7. Open a pull request
357
+
358
+ If you find a bug, [open an issue](https://github.com/sudogetshivam/pollen-transmit/issues). If you find a security vulnerability, please **do not** open a public issue — reach out directly.
359
+
360
+ ---
361
+
362
+ ## License
363
+
364
+ [MIT](LICENSE) — do whatever you want with it. Just don't blame us if your epidemic messages accidentally take over a small town's WiFi network.
365
+
366
+ ---
367
+
368
+ <p align="center">
369
+ <strong>Built for a world where the internet isn't guaranteed.</strong><br>
370
+ <em>Because sometimes the most reliable network is the one that walks.</em>
371
+ </p>
package/bin/ib.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * ib — CLI entry point
6
+ * All commands delegate to the daemon via IPC.
7
+ * The CLI itself does zero networking.
8
+ */
9
+
10
+ const { program } = require('commander');
11
+ const { startCommand } = require('../src/cli/commands/start');
12
+ const { stopCommand } = require('../src/cli/commands/stop');
13
+ const { scanCommand } = require('../src/cli/commands/scan');
14
+ const { sendCommand } = require('../src/cli/commands/send');
15
+ const { statusCommand } = require('../src/cli/commands/status');
16
+ const { syncCommand } = require('../src/cli/commands/sync');
17
+ const { fileCommand } = require('../src/cli/commands/file');
18
+
19
+ const VERSION = require('../package.json').version;
20
+
21
+ program
22
+ .name('ib')
23
+ .description(
24
+ '🌍 The Invisible Billion — Fully offline, peer-to-peer epidemic routing messenger.\n' +
25
+ ' Messages spread like a virus through human movement. Zero internet.'
26
+ )
27
+ .version(VERSION);
28
+
29
+ program
30
+ .command('start')
31
+ .description('Start The Invisible Billion background daemon')
32
+ .action(async () => {
33
+ await startCommand();
34
+ });
35
+
36
+ program
37
+ .command('stop')
38
+ .description('Stop The Invisible Billion background daemon')
39
+ .action(async () => {
40
+ await stopCommand();
41
+ });
42
+
43
+ program
44
+ .command('scan')
45
+ .description('List all IB peers discovered on the current network')
46
+ .action(async () => {
47
+ await scanCommand();
48
+ });
49
+
50
+ program
51
+ .command('send <identity> <message>')
52
+ .description('Send an encrypted message to an IB user')
53
+ .addHelpText('after', '\nExample:\n ib send raj@a3f2 "bhai notes bhej"')
54
+ .action(async (identity, message) => {
55
+ await sendCommand(identity, message);
56
+ });
57
+
58
+ program
59
+ .command('status <messageId>')
60
+ .description('Check delivery status of a message')
61
+ .action(async (messageId) => {
62
+ await statusCommand(messageId);
63
+ });
64
+
65
+ program
66
+ .command('sync')
67
+ .description('Manually trigger epidemic sync with peers on current network')
68
+ .action(async () => {
69
+ await syncCommand();
70
+ });
71
+
72
+ program
73
+ .command('file <identity> <filepath>')
74
+ .description('Send a file offline via epidemic routing (Phase 5)')
75
+ .addHelpText('after', '\nExample:\n ib file raj@a3f2 ./notes.pdf')
76
+ .action(async (identity, filepath) => {
77
+ await fileCommand(identity, filepath);
78
+ });
79
+
80
+ program.on('command:*', () => {
81
+ console.error(`\n❌ Unknown command: ${program.args.join(' ')}\n`);
82
+ program.help();
83
+ });
84
+
85
+ program.parse(process.argv);
86
+
87
+ // Show help if no command given
88
+ if (process.argv.length <= 2) {
89
+ program.help();
90
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "the-invisible-billion-cli",
3
+ "version": "1.0.0",
4
+ "description": "Fully offline, zero-internet, peer-to-peer epidemic routing CLI messenger — The Invisible Billion",
5
+ "main": "src/daemon/index.js",
6
+ "bin": {
7
+ "ib": "bin/ib.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/ib.js start"
11
+ },
12
+ "dependencies": {
13
+ "better-sqlite3": "^12.6.2",
14
+ "commander": "^12.1.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "p2p",
21
+ "mesh",
22
+ "offline",
23
+ "dtn",
24
+ "epidemic-routing",
25
+ "cli"
26
+ ],
27
+ "license": "MIT"
28
+ }
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ib file <identity> <filepath>
5
+ */
6
+ async function fileCommand(destination, filePath) {
7
+ if (!destination || !filePath) {
8
+ console.error('Usage: ib file <identity> <filepath>');
9
+ console.error('Example: ib file raj@a3f2 ./notes.pdf');
10
+ process.exit(1);
11
+ }
12
+
13
+ console.log('\n File transfer is coming in Phase 5.');
14
+ console.log(' Phase 5 will support:');
15
+ console.log(' • Chunked file encryption (AES-256-GCM per chunk)');
16
+ console.log(' • Fully offline transfer over TCP, same epidemic routing');
17
+ console.log(' • Automatic reassembly at the destination\n');
18
+ }
19
+
20
+ module.exports = { fileCommand };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const { runCommand } = require('../ipc');
4
+
5
+ async function scanCommand() {
6
+ await runCommand({ type: 'scan' }, (res) => {
7
+ if (!res.ok) {
8
+ console.error('❌', res.error);
9
+ return;
10
+ }
11
+
12
+ const peers = res.peers || [];
13
+ if (peers.length === 0) {
14
+ console.log('\n🔍 No IB peers found on current network.\n');
15
+ console.log(' Peers appear automatically when other IB daemons');
16
+ console.log(' start broadcasting on the same WiFi/LAN.\n');
17
+ return;
18
+ }
19
+
20
+ console.log(`\n🌍 IB peers on this network:\n`);
21
+ console.log(' Identity IP Address Last Seen Status');
22
+ console.log(' ───────────────── ──────────────── ──────────── ──────');
23
+ for (const peer of peers) {
24
+ const ago = formatAgo(peer.last_seen);
25
+ const ip = (peer.ip || 'unknown').padEnd(16);
26
+ const status = isOnline(peer.last_seen) ? '🟢 Online' : '⚪ Stale';
27
+ console.log(` ${peer.identity.padEnd(18)} ${ip} ${ago.padEnd(12)} ${status}`);
28
+ }
29
+ console.log(`\n Total: ${peers.length} peer(s)\n`);
30
+ });
31
+ }
32
+
33
+ function isOnline(epochMs) {
34
+ if (!epochMs) return false;
35
+ return (Date.now() - epochMs) < 90_000; // 90 s (6 heartbeats at 15s)
36
+ }
37
+
38
+ function formatAgo(epochMs) {
39
+ if (!epochMs) return 'unknown';
40
+ const diff = Date.now() - epochMs;
41
+ const s = Math.floor(diff / 1000);
42
+ if (s < 60) return `${s}s ago`;
43
+ const m = Math.floor(s / 60);
44
+ if (m < 60) return `${m}m ago`;
45
+ const h = Math.floor(m / 60);
46
+ return `${h}h ago`;
47
+ }
48
+
49
+ module.exports = { scanCommand };
50
+