@ohmaseclaro/fleetwatch 0.1.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/.env.example ADDED
@@ -0,0 +1,25 @@
1
+ # fleetwatch configuration
2
+ # Copy to .env (project root) or ~/.config/fleetwatch/.env
3
+
4
+ # ─── ngrok tunnel (on by default) ─────────────────────────────────────────
5
+ # Get a free authtoken at https://dashboard.ngrok.com/get-started/your-authtoken
6
+ NGROK_AUTHTOKEN=
7
+
8
+ # Set to 1 to disable the tunnel entirely (LAN-only access).
9
+ # NGROK_DISABLED=1
10
+
11
+ # ─── Password protection (off by default) ─────────────────────────────────
12
+ # When set, every device must enter this password before connecting.
13
+ # Recommended if ngrok is enabled. Stored in memory as a bcrypt hash only.
14
+ # PASSWORD=correct horse battery staple
15
+
16
+ # ─── JWT signing secret (auto-generated if not set) ───────────────────────
17
+ # Override only if you want JWTs to survive config wipes. 48+ random chars.
18
+ # JWT_SECRET=
19
+
20
+ # ─── npm publish (release script only — not used by the running daemon) ───
21
+ # Get an Automation or "bypass 2FA" Granular token at
22
+ # https://www.npmjs.com/settings/~/tokens
23
+ # `npm publish` (and `./scripts/release.sh`) read this via the project's
24
+ # .npmrc to authenticate without an interactive 2FA prompt.
25
+ # NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Augusto Claro
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,156 @@
1
+ # fleetwatch
2
+
3
+ > Watch every Claude Code, Cowork, and Cursor session from your phone — live.
4
+
5
+ A single command starts a local daemon that tails your AI coding agent
6
+ transcripts and serves a mobile-friendly web UI. Pair your phone via QR code
7
+ or open it from anywhere via a free ngrok tunnel.
8
+
9
+ ```bash
10
+ npx @ohmaseclaro/fleetwatch
11
+ # or install globally:
12
+ npm install -g @ohmaseclaro/fleetwatch && fleetwatch
13
+ ```
14
+
15
+ ## What it does
16
+
17
+ - **Tracks Claude Code, Cowork, and Cursor** automatically — discovers data
18
+ in standard locations and falls back to a bounded filesystem search if
19
+ you've installed in a non-standard place.
20
+ - **Mobile-first PWA** — open the URL on your phone, add to home screen,
21
+ watch sessions live as they run on your desktop.
22
+ - **Source tabs with icons** — filter to Claude, Cowork, Cursor, or All.
23
+ Each row shows status (running / awaiting / errored / idle) with a
24
+ colored stripe on the left edge for in-flight sessions.
25
+ - **Image attachments** — screenshots from the screenshot tool, user-pasted
26
+ images, render inline with a tap-to-zoom lightbox.
27
+ - **Session info modal** — tap (i) on any session to see the full transcript
28
+ path, project, git branch, session ID, file size, with copy-to-clipboard.
29
+ - **Auto-tunnel via ngrok** — works on any network when you have a free
30
+ authtoken; auto-picks it up from your existing `~/.../ngrok.yml` if you've
31
+ run `ngrok config add-authtoken …` before.
32
+ - **Optional password** — set `PASSWORD=…` in `.env` to require a password
33
+ before any device can connect. Bcrypt-hashed in memory, never on disk.
34
+ - **Read-only** — never writes to source data; opens DBs read-only; never
35
+ follows symlinks during discovery.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ # Install globally
41
+ npm install -g @ohmaseclaro/fleetwatch
42
+
43
+ # Run — auto-discovers Claude / Cursor data, prints QR code
44
+ fleetwatch
45
+ ```
46
+
47
+ Scan the QR with your phone. You're done.
48
+
49
+ ### First-time ngrok (optional but recommended)
50
+
51
+ For access from anywhere (cellular, coffee shops, etc.) you'll need a free
52
+ ngrok authtoken:
53
+
54
+ 1. Sign up: <https://dashboard.ngrok.com/signup>
55
+ 2. Copy your authtoken: <https://dashboard.ngrok.com/get-started/your-authtoken>
56
+ 3. `fleetwatch --ngrok-authtoken <your-token>` (persisted for future runs)
57
+
58
+ Or set `NGROK_AUTHTOKEN=…` in `.env`. Or — if you've already run
59
+ `ngrok config add-authtoken …` — fleetwatch picks it up automatically.
60
+
61
+ ## Configuration
62
+
63
+ All optional. Defaults work out of the box.
64
+
65
+ | Env var | Description |
66
+ |---|---|
67
+ | `NGROK_AUTHTOKEN` | ngrok free-tier authtoken. Required for the public tunnel. |
68
+ | `NGROK_DISABLED=1` | Skip the ngrok tunnel even if a token is available. |
69
+ | `CURSOR_DISABLED=1` | Skip the Cursor provider entirely. |
70
+ | `PASSWORD` | Optional password — devices must enter it to connect. Bcrypt-hashed in memory only. |
71
+ | `JWT_SECRET` | JWT signing secret (auto-generated + persisted otherwise). |
72
+ | `CLAUDE_PROJECTS_DIR` | Override Claude Code projects dir. |
73
+ | `COWORK_DIR` | Override Cowork sessions dir. |
74
+ | `CLAUDE_HISTORY_FILE` | Override `history.jsonl` path. |
75
+ | `CURSOR_DB_PATH` | Override Cursor `state.vscdb` path. |
76
+
77
+ `.env` files are read from `./.env` and `~/.config/fleetwatch/.env`
78
+ (in that order; shell env always wins).
79
+
80
+ ## CLI
81
+
82
+ ```
83
+ fleetwatch [options]
84
+
85
+ --port, -p <port> Port to listen on (default 7878)
86
+ --host <host> Bind address (default 0.0.0.0)
87
+ --quiet, -q Suppress QR / banner output
88
+ --ngrok-authtoken <t> ngrok authtoken (persisted to config)
89
+ --no-ngrok Disable ngrok for THIS run only (ephemeral)
90
+ --ngrok Force-enable ngrok, overriding stored config
91
+ --reset-ngrok Clear the persisted "ngrok disabled" flag
92
+ --no-cursor Skip the Cursor IDE provider
93
+ --help, -h Show help
94
+ ```
95
+
96
+ ## Providers
97
+
98
+ Out of the box:
99
+
100
+ | Provider | Source data |
101
+ |---|---|
102
+ | **Claude Code** | `~/.claude/projects/*.jsonl` (the CLI agent) |
103
+ | **Cowork** | `~/Library/Application Support/Claude/local-agent-mode-sessions/.../*.jsonl` |
104
+ | **Cursor** | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` |
105
+
106
+ Discovery is robust — each provider tries the default location, then an
107
+ ordered list of OS-specific alternatives, then a bounded filesystem search
108
+ under known app-data dirs (`~/.config/`, `~/Library/Application Support/`),
109
+ with a mandatory `verify()` predicate that opens the candidate and
110
+ confirms it's actually that provider's data (so VSCode's `state.vscdb`
111
+ never gets mistaken for Cursor's).
112
+
113
+ Adding a new provider (Aider, Continue, anything else) is small — extend
114
+ `BaseProvider`, declare a `DiscoverySpec`, implement `onStart` /
115
+ `backfillSession`. See [docs/ADD_A_PROVIDER.md](docs/ADD_A_PROVIDER.md).
116
+
117
+ ## Security
118
+
119
+ - All connections require either the pairing token (in the QR URL) or a
120
+ successful login via password (if `PASSWORD` is set).
121
+ - After login, the client gets a 30-day JWT; the pairing token is no
122
+ longer used.
123
+ - WebSocket connections authenticate via JWT.
124
+ - Image attachments served from an authed endpoint with content-addressed
125
+ hashes — no enumeration possible.
126
+ - No telemetry. No external calls (except ngrok if enabled).
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ git clone https://github.com/ohmaseclaro/fleetwatch
132
+ cd fleetwatch
133
+ npm install
134
+ npm run dev:web # Vite HMR on :5173
135
+ npm run dev:server # daemon with watch on :7878 (separate terminal)
136
+ ```
137
+
138
+ Production build:
139
+
140
+ ```bash
141
+ npm run build # web → dist/web, server → dist/server
142
+ npm start # run the compiled daemon
143
+ ```
144
+
145
+ ## Releasing
146
+
147
+ ```bash
148
+ npm run release # bumps version, builds, publishes, tags
149
+ ```
150
+
151
+ See [scripts/release.sh](scripts/release.sh) for details, or
152
+ [docs/PUBLISHING.md](docs/PUBLISHING.md) for first-time setup.
153
+
154
+ ## License
155
+
156
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,68 @@
1
+ /**
2
+ * In-memory store for image attachments extracted from agent JSONL files.
3
+ *
4
+ * Why a store instead of just passing base64 over the WebSocket?
5
+ * - Photos can be 3–5 MB; our WS payload cap is 1 MB
6
+ * - We don't want to re-transmit the same image on every list refresh
7
+ * - HTTP fetch gives us caching + range support for free
8
+ *
9
+ * Strategy: dedupe by sha256, LRU evict to keep memory bounded, serve via
10
+ * authed HTTP endpoint. Lives only in process memory — never persisted.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ /** Hard cap on total cached bytes; oldest entries get evicted first. */
14
+ const MAX_TOTAL_BYTES = 200 * 1024 * 1024; // 200 MB
15
+ /** Skip any single attachment larger than this — base64 round-trip protection. */
16
+ const MAX_SINGLE_BYTES = 20 * 1024 * 1024; // 20 MB
17
+ export class AttachmentStore {
18
+ entries = new Map();
19
+ totalBytes = 0;
20
+ /**
21
+ * Store an attachment and return its content-addressed hash. Returns null
22
+ * if the attachment is too large to keep.
23
+ */
24
+ put(buffer, mediaType) {
25
+ if (buffer.byteLength > MAX_SINGLE_BYTES)
26
+ return null;
27
+ const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 32);
28
+ // Dedupe: if we already have this hash, just refresh the LRU timestamp.
29
+ const existing = this.entries.get(hash);
30
+ if (existing) {
31
+ existing.lastAccess = Date.now();
32
+ return hash;
33
+ }
34
+ this.entries.set(hash, {
35
+ buffer,
36
+ mediaType,
37
+ sizeBytes: buffer.byteLength,
38
+ lastAccess: Date.now(),
39
+ });
40
+ this.totalBytes += buffer.byteLength;
41
+ this.evictIfNeeded();
42
+ return hash;
43
+ }
44
+ get(hash) {
45
+ const entry = this.entries.get(hash);
46
+ if (!entry)
47
+ return null;
48
+ entry.lastAccess = Date.now();
49
+ return entry;
50
+ }
51
+ /** Drop oldest-accessed entries until under the byte cap. */
52
+ evictIfNeeded() {
53
+ if (this.totalBytes <= MAX_TOTAL_BYTES)
54
+ return;
55
+ const sorted = Array.from(this.entries.entries()).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
56
+ while (this.totalBytes > MAX_TOTAL_BYTES && sorted.length > 0) {
57
+ const [hash, entry] = sorted.shift();
58
+ this.entries.delete(hash);
59
+ this.totalBytes -= entry.sizeBytes;
60
+ }
61
+ }
62
+ stats() {
63
+ return { count: this.entries.size, bytes: this.totalBytes };
64
+ }
65
+ }
66
+ /** Module-level singleton — one store per daemon process. */
67
+ export const attachmentStore = new AttachmentStore();
68
+ //# sourceMappingURL=attachmentStore.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Authentication: optional bcrypt-hashed password + JWT issuance for ongoing
3
+ * access. State is in-memory only — the password hash never touches disk.
4
+ *
5
+ * Two modes:
6
+ * - No password (default): /api/login with a valid pairing token issues a JWT.
7
+ * - Password set in env: /api/login with a valid password issues a JWT.
8
+ * (Pairing token alone is not enough.)
9
+ *
10
+ * The JWT is the long-lived credential the client uses for all subsequent
11
+ * requests (WS + HTTP). 30-day expiry by default.
12
+ */
13
+ import bcrypt from "bcryptjs";
14
+ import jwt from "jsonwebtoken";
15
+ let state = null;
16
+ export function initAuth(opts) {
17
+ const passwordHash = opts.password
18
+ ? bcrypt.hashSync(opts.password, 10)
19
+ : null;
20
+ state = {
21
+ passwordHash,
22
+ jwtSecret: opts.jwtSecret,
23
+ jwtExpiresIn: opts.jwtExpiresIn ?? "30d",
24
+ pairingToken: opts.pairingToken,
25
+ };
26
+ }
27
+ /** Update the pairing token (e.g. after rotation) without re-initializing. */
28
+ export function setPairingToken(token) {
29
+ if (state)
30
+ state.pairingToken = token;
31
+ }
32
+ /** Update the password (e.g. set/clear via Settings UI). */
33
+ export function setPassword(plaintext) {
34
+ if (!state)
35
+ throw new Error("auth not initialized");
36
+ state.passwordHash = plaintext ? bcrypt.hashSync(plaintext, 10) : null;
37
+ }
38
+ export function isPasswordRequired() {
39
+ return !!state?.passwordHash;
40
+ }
41
+ export async function verifyPassword(plaintext) {
42
+ if (!state?.passwordHash)
43
+ return false;
44
+ return bcrypt.compare(plaintext, state.passwordHash);
45
+ }
46
+ export function verifyPairingToken(token) {
47
+ if (!state || !token)
48
+ return false;
49
+ return token === state.pairingToken;
50
+ }
51
+ export function issueJwt(subject = "user") {
52
+ if (!state)
53
+ throw new Error("auth not initialized");
54
+ const token = jwt.sign({ sub: subject }, state.jwtSecret, {
55
+ expiresIn: state.jwtExpiresIn,
56
+ });
57
+ const decoded = jwt.decode(token);
58
+ return {
59
+ token,
60
+ expiresAt: decoded ? decoded.exp * 1000 : Date.now() + 30 * 24 * 3600_000,
61
+ };
62
+ }
63
+ export function verifyJwt(token) {
64
+ if (!state || !token)
65
+ return null;
66
+ try {
67
+ return jwt.verify(token, state.jwtSecret);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /**
74
+ * Unified auth check used by HTTP + WS routes.
75
+ * Accepts a JWT (preferred) OR the pairing token (when password is not required).
76
+ * Returns true if authorized.
77
+ */
78
+ export function isAuthorized(token) {
79
+ if (!token)
80
+ return false;
81
+ if (verifyJwt(token))
82
+ return true;
83
+ // Pairing token alone is only enough when no password is configured.
84
+ if (!isPasswordRequired() && verifyPairingToken(token))
85
+ return true;
86
+ return false;
87
+ }
88
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Load .env files. Priority (first match per key wins — dotenv's
3
+ * "don't clobber existing" semantics):
4
+ *
5
+ * 1. process.env (already set in shell)
6
+ * 2. ./.env in current working directory
7
+ * 3. ~/.config/fleetwatch/.env
8
+ * 4. ~/.config/claude-watcher/.env (legacy from the rename — auto-picked up)
9
+ *
10
+ * Shell env > project .env > user-global .env. Ship defaults in your global
11
+ * file and override per-project.
12
+ */
13
+ import path from "node:path";
14
+ import os from "node:os";
15
+ import { existsSync } from "node:fs";
16
+ import dotenv from "dotenv";
17
+ let loaded = false;
18
+ export function loadEnv() {
19
+ if (loaded)
20
+ return;
21
+ loaded = true;
22
+ const cwdEnv = path.join(process.cwd(), ".env");
23
+ const globalEnv = path.join(os.homedir(), ".config", "fleetwatch", ".env");
24
+ const legacyGlobalEnv = path.join(os.homedir(), ".config", "claude-watcher", ".env");
25
+ // dotenv.config({ override: false }) → don't overwrite already-set vars
26
+ if (existsSync(cwdEnv))
27
+ dotenv.config({ path: cwdEnv, override: false });
28
+ if (existsSync(globalEnv))
29
+ dotenv.config({ path: globalEnv, override: false });
30
+ if (existsSync(legacyGlobalEnv))
31
+ dotenv.config({ path: legacyGlobalEnv, override: false });
32
+ }
33
+ export function envFlag(name) {
34
+ const v = process.env[name];
35
+ if (!v)
36
+ return false;
37
+ return v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes";
38
+ }
39
+ export function envString(name) {
40
+ const v = process.env[name];
41
+ return v && v.length > 0 ? v : undefined;
42
+ }
43
+ //# sourceMappingURL=env.js.map