@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.
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { existsSync } from "node:fs";
5
+ import { loadEnv, envFlag, envString } from "./env.js";
6
+ // Load .env BEFORE any other module reads process.env.
7
+ loadEnv();
8
+ import { SessionRegistry } from "./registry.js";
9
+ import { Watcher } from "./watcher.js";
10
+ import { ProviderManager } from "./providers/types.js";
11
+ import { CursorProvider } from "./providers/cursor.js";
12
+ import { loadOrInitConfig, buildPairingPayload, pickLanIp, saveConfig } from "./pairing.js";
13
+ import { startServer } from "./server.js";
14
+ import { startTunnel, stopTunnel, activeTunnel } from "./tunnel.js";
15
+ import { initAuth, isPasswordRequired } from "./auth.js";
16
+ import { findNgrokAuthtoken } from "./ngrokConfig.js";
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const AGENT_VERSION = "0.1.0";
19
+ function parseArgs(argv) {
20
+ const args = {
21
+ port: parseInt(process.env.PORT ?? "7878", 10),
22
+ host: process.env.HOST ?? "0.0.0.0",
23
+ quiet: false,
24
+ };
25
+ for (let i = 2; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a === "--port" || a === "-p")
28
+ args.port = parseInt(argv[++i] ?? "7878", 10);
29
+ else if (a === "--host")
30
+ args.host = argv[++i] ?? "0.0.0.0";
31
+ else if (a === "--include-cowork")
32
+ args.includeCowork = true;
33
+ else if (a === "--quiet" || a === "-q")
34
+ args.quiet = true;
35
+ else if (a === "--ngrok-authtoken")
36
+ args.ngrokAuthtoken = argv[++i];
37
+ else if (a === "--no-ngrok")
38
+ args.ngrokDisabled = true;
39
+ else if (a === "--ngrok")
40
+ args.ngrokForceOn = true;
41
+ else if (a === "--reset-ngrok")
42
+ args.ngrokReset = true;
43
+ else if (a === "--no-cursor")
44
+ args.cursorDisabled = true;
45
+ else if (a === "--help" || a === "-h") {
46
+ printHelp();
47
+ process.exit(0);
48
+ }
49
+ }
50
+ return args;
51
+ }
52
+ function printHelp() {
53
+ console.log(`fleetwatch v${AGENT_VERSION}
54
+
55
+ Usage: fleetwatch [options]
56
+
57
+ Options:
58
+ --port, -p <port> Port to listen on (default 7878)
59
+ --host <host> Bind address (default 0.0.0.0 — your LAN)
60
+ --include-cowork Also surface Cowork desktop sessions
61
+ --quiet, -q Suppress QR / banner output
62
+ --ngrok-authtoken <t> ngrok authtoken for auto-started HTTPS tunnel (free tier OK)
63
+ --no-ngrok Disable the ngrok tunnel for THIS run (ephemeral)
64
+ --ngrok Force-enable ngrok for this run, overriding stored config
65
+ --reset-ngrok Clear the persisted "ngrok disabled" flag so it stays on
66
+ --no-cursor Skip the Cursor IDE provider (Claude only)
67
+ --help, -h Show this help
68
+
69
+ Environment / .env (reads ./.env then ~/.config/fleetwatch/.env):
70
+ NGROK_AUTHTOKEN ngrok free-tier authtoken (required for tunnel)
71
+ NGROK_DISABLED=1 Disable ngrok tunnel
72
+ CURSOR_DISABLED=1 Disable Cursor IDE provider
73
+ PASSWORD Optional password (bcrypt-hashed in memory only)
74
+ JWT_SECRET Optional JWT signing secret (auto-generated otherwise)
75
+
76
+ The daemon serves a mobile-friendly web UI. ngrok is enabled by default so
77
+ you can reach it from anywhere; set NGROK_AUTHTOKEN once (free at
78
+ https://dashboard.ngrok.com/get-started/your-authtoken) or use --no-ngrok
79
+ to stay LAN-only.`);
80
+ }
81
+ /**
82
+ * Resolve the ngrok authtoken in priority order:
83
+ * 1. CLI `--ngrok-authtoken`
84
+ * 2. env `NGROK_AUTHTOKEN`
85
+ * 3. fleetwatch's own config (Settings UI / past CLI saves)
86
+ * 4. ngrok's own config file (~/Library/Application Support/ngrok/ngrok.yml)
87
+ * — so users who've run `ngrok config add-authtoken` get it for free.
88
+ *
89
+ * Returns null when no token is reachable anywhere.
90
+ */
91
+ function resolveNgrokAuthtoken(args, config) {
92
+ if (args.ngrokAuthtoken)
93
+ return { token: args.ngrokAuthtoken, source: "cli" };
94
+ const envToken = envString("NGROK_AUTHTOKEN");
95
+ if (envToken)
96
+ return { token: envToken, source: "env" };
97
+ if (config.ngrokAuthtoken)
98
+ return { token: config.ngrokAuthtoken, source: "config" };
99
+ const fromNgrok = findNgrokAuthtoken();
100
+ if (fromNgrok)
101
+ return { token: fromNgrok.authtoken, source: "ngrok-config", sourcePath: fromNgrok.source };
102
+ return null;
103
+ }
104
+ /**
105
+ * Decide whether ngrok is disabled and report which source disabled it.
106
+ * Precedence: --ngrok force-on > CLI --no-ngrok > env NGROK_DISABLED > config.
107
+ * Returning `null` means ngrok is enabled.
108
+ */
109
+ function resolveNgrokDisabled(args, config) {
110
+ if (args.ngrokForceOn)
111
+ return null; // explicit override wins
112
+ if (args.ngrokDisabled)
113
+ return "cli";
114
+ if (envFlag("NGROK_DISABLED"))
115
+ return "env";
116
+ if (config.ngrokDisabled)
117
+ return "config";
118
+ return null;
119
+ }
120
+ async function main() {
121
+ const args = parseArgs(process.argv);
122
+ const config = await loadOrInitConfig();
123
+ if (args.includeCowork !== undefined) {
124
+ config.preferences.includeCowork = args.includeCowork;
125
+ await saveConfig(config);
126
+ }
127
+ // CLI-supplied authtoken IS persisted (one-time setup convenience).
128
+ if (args.ngrokAuthtoken) {
129
+ config.ngrokAuthtoken = args.ngrokAuthtoken;
130
+ // Setting an authtoken implies you want ngrok on — clear any stale disable.
131
+ config.ngrokDisabled = undefined;
132
+ await saveConfig(config);
133
+ }
134
+ // `--reset-ngrok` or `--ngrok`: clear the persisted disabled flag so the
135
+ // user recovers from a stuck state without editing config.json by hand.
136
+ if (args.ngrokReset || args.ngrokForceOn) {
137
+ if (config.ngrokDisabled) {
138
+ config.ngrokDisabled = undefined;
139
+ await saveConfig(config);
140
+ }
141
+ }
142
+ // NOTE: `--no-ngrok` is intentionally NOT persisted to config. It's an
143
+ // ephemeral, single-run override. Persistent disable should be set via
144
+ // the Settings UI or NGROK_DISABLED=1 env var.
145
+ // --- Initialize auth: optional password (from env) + JWT signing ---
146
+ initAuth({
147
+ password: envString("PASSWORD"),
148
+ jwtSecret: envString("JWT_SECRET") ?? config.jwtSecret,
149
+ pairingToken: config.token,
150
+ });
151
+ const log = (msg) => { if (!args.quiet)
152
+ console.error(msg); };
153
+ const registry = new SessionRegistry();
154
+ registry.setMaxListeners(64);
155
+ // --- Providers: assemble all data sources behind a single manager ---
156
+ const providers = new ProviderManager();
157
+ providers.add(new Watcher({ registry, onLog: log }));
158
+ const cursorDisabled = args.cursorDisabled || envFlag("CURSOR_DISABLED");
159
+ if (!cursorDisabled) {
160
+ try {
161
+ providers.add(new CursorProvider({ registry, onLog: log }));
162
+ }
163
+ catch (err) {
164
+ log(`[cursor] disabled: ${err.message}`);
165
+ }
166
+ }
167
+ else {
168
+ log(`[cursor] disabled (--no-cursor or CURSOR_DISABLED=1)`);
169
+ }
170
+ // Locate web bundle.
171
+ const candidates = [
172
+ path.join(__dirname, "..", "web"), // dist/server/index.js -> dist/web
173
+ path.join(__dirname, "..", "..", "dist", "web"),
174
+ path.join(process.cwd(), "dist", "web"),
175
+ ];
176
+ const webRoot = candidates.find((p) => existsSync(p)) ?? candidates[0];
177
+ await providers.startAll();
178
+ /** (Re)start the ngrok tunnel; called at boot and when the token changes. */
179
+ async function restartTunnel(authtoken) {
180
+ await startTunnel({ port: args.port, authtoken, onLog: log });
181
+ }
182
+ const fastify = await startServer({
183
+ port: args.port,
184
+ host: args.host,
185
+ config,
186
+ registry,
187
+ providers,
188
+ webRoot,
189
+ agentVersion: AGENT_VERSION,
190
+ onLog: log,
191
+ onConfigChanged: (_cfg) => { },
192
+ onTunnelAuthtoken: async (authtoken) => {
193
+ config.ngrokAuthtoken = authtoken;
194
+ await restartTunnel(authtoken);
195
+ },
196
+ });
197
+ // --- Start the ngrok tunnel (non-blocking; QR is printed first with LAN URL,
198
+ // then re-printed once the tunnel URL is known). ---
199
+ const resolved = resolveNgrokAuthtoken(args, config);
200
+ const ngrokToken = resolved?.token;
201
+ const disableSource = resolveNgrokDisabled(args, config);
202
+ const ngrokDisabled = disableSource !== null;
203
+ const passwordOn = isPasswordRequired();
204
+ const printBanner = async () => {
205
+ if (args.quiet)
206
+ return;
207
+ const lan = pickLanIp() ?? "127.0.0.1";
208
+ const tunnel = activeTunnel();
209
+ const payload = await buildPairingPayload(lan, args.port, config.token, tunnel?.url);
210
+ const url = payload.url;
211
+ console.log("");
212
+ console.log(" \x1b[1m\x1b[38;5;208mfleetwatch\x1b[0m v" + AGENT_VERSION);
213
+ console.log(" ─────────────────────────────────────────");
214
+ console.log("");
215
+ if (tunnel) {
216
+ console.log(" Open this on your phone \x1b[32m(works anywhere — via ngrok)\x1b[0m:");
217
+ }
218
+ else {
219
+ console.log(" Open this on your phone (same Wi-Fi):");
220
+ }
221
+ console.log("");
222
+ console.log(` \x1b[1m\x1b[38;5;208m${url}\x1b[0m`);
223
+ console.log("");
224
+ console.log(" Or scan the QR code below:");
225
+ console.log("");
226
+ process.stdout.write(payload.qrAscii);
227
+ console.log("");
228
+ console.log(` Host: ${config.hostLabel}`);
229
+ console.log(` Port: ${args.port}`);
230
+ if (tunnel) {
231
+ const src = tokenSourceLabel(resolved);
232
+ console.log(` Tunnel: \x1b[32m${tunnel.url}\x1b[0m${src ? ` \x1b[90m(${src})\x1b[0m` : ""}`);
233
+ }
234
+ else if (disableSource === "cli") {
235
+ console.log(` Tunnel: \x1b[90mdisabled\x1b[0m (--no-ngrok)`);
236
+ }
237
+ else if (disableSource === "env") {
238
+ console.log(` Tunnel: \x1b[90mdisabled\x1b[0m (NGROK_DISABLED=1 in env)`);
239
+ }
240
+ else if (disableSource === "config") {
241
+ console.log(` Tunnel: \x1b[33mdisabled by stored config\x1b[0m`);
242
+ console.log(` Run with \x1b[1m--reset-ngrok\x1b[0m to clear, or toggle in Settings.`);
243
+ }
244
+ else if (ngrokToken) {
245
+ const src = tokenSourceLabel(resolved);
246
+ console.log(` Tunnel: connecting…${src ? ` \x1b[90m(${src})\x1b[0m` : ""}`);
247
+ }
248
+ else {
249
+ console.log(` Tunnel: \x1b[33mnot configured\x1b[0m (LAN-only — same Wi-Fi required)`);
250
+ }
251
+ console.log(` Auth: ${passwordOn ? "\x1b[32mpassword required\x1b[0m" : "\x1b[33mpairing token only\x1b[0m (set PASSWORD to add a password)"}`);
252
+ console.log(` Cowork: ${config.preferences.includeCowork ? "on" : "off"} (toggle in Settings)`);
253
+ console.log("");
254
+ // Onboarding panel — only shown when ngrok isn't set up yet.
255
+ if (!tunnel && !ngrokDisabled && !ngrokToken) {
256
+ printNgrokSetupHelp(args.port);
257
+ }
258
+ console.log(" Press Ctrl+C to stop.");
259
+ console.log("");
260
+ };
261
+ // Print initial banner immediately with LAN URL.
262
+ await printBanner();
263
+ // Then start ngrok in background; re-print banner when ready.
264
+ if (ngrokToken && !ngrokDisabled) {
265
+ restartTunnel(ngrokToken).then(() => {
266
+ if (activeTunnel()) {
267
+ // Clear last N lines and reprint (simple approach: just print again).
268
+ console.log(" \x1b[33m↻ ngrok tunnel ready — updated QR:\x1b[0m");
269
+ printBanner();
270
+ }
271
+ });
272
+ }
273
+ const shutdown = async () => {
274
+ console.log("\nShutting down…");
275
+ await stopTunnel().catch(() => { });
276
+ await providers.stopAll();
277
+ await fastify.close();
278
+ process.exit(0);
279
+ };
280
+ process.on("SIGINT", shutdown);
281
+ process.on("SIGTERM", shutdown);
282
+ }
283
+ /**
284
+ * Short human label for where the ngrok authtoken came from, shown in the
285
+ * banner so users understand why a tunnel is/isn't starting.
286
+ */
287
+ function tokenSourceLabel(r) {
288
+ if (!r)
289
+ return "";
290
+ switch (r.source) {
291
+ case "cli": return "token via --ngrok-authtoken";
292
+ case "env": return "token via NGROK_AUTHTOKEN env";
293
+ case "config": return "token via stored config";
294
+ case "ngrok-config":
295
+ return r.sourcePath
296
+ ? `token via ngrok.yml (${shortenHome(r.sourcePath)})`
297
+ : "token via ngrok.yml";
298
+ }
299
+ }
300
+ function shortenHome(p) {
301
+ const home = process.env.HOME;
302
+ return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
303
+ }
304
+ /**
305
+ * Print a friendly onboarding panel for first-time ngrok setup.
306
+ * Uses OSC-8 terminal hyperlinks where supported (iTerm2, modern terminals)
307
+ * — degrades to plain text in older terminals.
308
+ */
309
+ function printNgrokSetupHelp(port) {
310
+ const link = (url, label) => `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
311
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
312
+ const dim = (s) => `\x1b[90m${s}\x1b[0m`;
313
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
314
+ console.log(` ${yellow("┌──────── Make this reachable from anywhere ────────┐")}`);
315
+ console.log(` ${yellow("│")}`);
316
+ console.log(` ${yellow("│")} ngrok creates a public HTTPS URL so your phone`);
317
+ console.log(` ${yellow("│")} works on cellular, coffee shops, anywhere.`);
318
+ console.log(` ${yellow("│")} ${dim("(Free tier, no credit card required.)")}`);
319
+ console.log(` ${yellow("│")}`);
320
+ console.log(` ${yellow("│")} ${bold("1.")} Sign up:`);
321
+ console.log(` ${yellow("│")} ${link("https://dashboard.ngrok.com/signup", "https://dashboard.ngrok.com/signup")}`);
322
+ console.log(` ${yellow("│")}`);
323
+ console.log(` ${yellow("│")} ${bold("2.")} Copy your authtoken:`);
324
+ console.log(` ${yellow("│")} ${link("https://dashboard.ngrok.com/get-started/your-authtoken", "https://dashboard.ngrok.com/get-started/your-authtoken")}`);
325
+ console.log(` ${yellow("│")}`);
326
+ console.log(` ${yellow("│")} ${bold("3.")} Restart with the token:`);
327
+ console.log(` ${yellow("│")} ${bold("fleetwatch --ngrok-authtoken <your-token>")}`);
328
+ console.log(` ${yellow("│")} ${dim("or set NGROK_AUTHTOKEN=... in .env")}`);
329
+ console.log(` ${yellow("│")} ${dim("or paste it in Settings once paired locally")}`);
330
+ console.log(` ${yellow("│")}`);
331
+ console.log(` ${yellow("└────────────────────────────────────────────────────┘")}`);
332
+ console.log("");
333
+ }
334
+ main().catch((err) => {
335
+ console.error("Fatal:", err);
336
+ process.exit(1);
337
+ });
338
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Liberal JSONL line parser.
3
+ *
4
+ * Real Claude Code lines come in many shapes. Rather than modeling each
5
+ * variant exhaustively, we extract the fields we care about and tolerate
6
+ * everything else. Unknown line shapes still yield a usable SessionEvent.
7
+ */
8
+ // Lines we drop entirely — pure metadata, not user-facing events.
9
+ const META_TYPES = new Set(["last-prompt", "queue-operation", "todo-update", "ai-title", "custom-title"]);
10
+ /**
11
+ * Extract session title metadata from a raw JSONL line if present.
12
+ * Returns null when the line isn't a title line.
13
+ */
14
+ export function parseTitleLine(line) {
15
+ const trimmed = line.trim();
16
+ if (!trimmed)
17
+ return null;
18
+ let raw;
19
+ try {
20
+ raw = JSON.parse(trimmed);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ if (typeof raw !== "object" || raw === null)
26
+ return null;
27
+ if (raw.type === "ai-title" && typeof raw.aiTitle === "string") {
28
+ return { aiTitle: raw.aiTitle };
29
+ }
30
+ if (raw.type === "custom-title" && typeof raw.customTitle === "string") {
31
+ return { customTitle: raw.customTitle };
32
+ }
33
+ return null;
34
+ }
35
+ export function parseLine(line, sessionId, opts = {}) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed)
38
+ return null;
39
+ let raw;
40
+ try {
41
+ raw = JSON.parse(trimmed);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ if (typeof raw !== "object" || raw === null)
47
+ return null;
48
+ if (typeof raw.type === "string" && META_TYPES.has(raw.type))
49
+ return null;
50
+ const parsedTs = parseTimestamp(raw.timestamp);
51
+ // Drop lines with no timestamp — they pollute lastEventAt. Real conversation
52
+ // events always carry one.
53
+ if (parsedTs === null)
54
+ return null;
55
+ const ts = parsedTs;
56
+ const type = normalizeType(raw.type);
57
+ const ev = {
58
+ sessionId,
59
+ ts,
60
+ type,
61
+ uuid: typeof raw.uuid === "string" ? raw.uuid : undefined,
62
+ };
63
+ // user messages can carry plain string content or assistant-style content arrays
64
+ if (type === "user") {
65
+ ev.text = extractMessageText(raw.message) ?? extractMessageText(raw);
66
+ const images = extractImages(raw.message, opts.storeImage);
67
+ if (images.length > 0)
68
+ ev.images = images;
69
+ // a user line may also bundle tool_result blocks inside message.content
70
+ const toolResults = extractToolResults(raw.message);
71
+ if (toolResults.length > 0) {
72
+ // promote to a tool_result event (use the first; others rare)
73
+ const first = toolResults[0];
74
+ ev.type = "tool_result";
75
+ ev.toolUseRef = first.tool_use_id;
76
+ ev.toolResultText = first.text;
77
+ ev.toolResultIsError = first.is_error;
78
+ // keep the user text only if there's something beyond the tool_result
79
+ if (!ev.text || ev.text.length < 4)
80
+ ev.text = undefined;
81
+ }
82
+ }
83
+ else if (type === "assistant") {
84
+ const { text, toolUses, thinking, usage, model } = extractAssistantBlocks(raw.message);
85
+ ev.text = text;
86
+ ev.thinking = thinking;
87
+ ev.model = model ?? (typeof raw.message?.model === "string" ? raw.message.model : undefined);
88
+ if (usage) {
89
+ ev.inputTokens = usage.input_tokens;
90
+ ev.outputTokens = usage.output_tokens;
91
+ ev.cacheReadTokens = usage.cache_read_input_tokens;
92
+ }
93
+ if (toolUses.length > 0) {
94
+ // emit the first tool_use here; additional tool_uses in the same line
95
+ // are returned by parseLineMulti below
96
+ const first = toolUses[0];
97
+ ev.toolName = first.name;
98
+ ev.toolInput = first.input;
99
+ ev.toolUseId = first.id;
100
+ // we keep the assistant text alongside if present
101
+ }
102
+ }
103
+ else if (type === "tool_use") {
104
+ ev.toolName = raw.name;
105
+ ev.toolInput = raw.input;
106
+ ev.toolUseId = raw.id;
107
+ }
108
+ else if (type === "tool_result") {
109
+ ev.toolUseRef = raw.tool_use_id;
110
+ ev.toolResultText = stringifyMaybe(raw.content);
111
+ ev.toolResultIsError = raw.is_error === true;
112
+ }
113
+ else if (type === "attachment") {
114
+ ev.attachmentKind = raw.attachment?.type;
115
+ if (typeof raw.attachment?.content === "string") {
116
+ ev.text = raw.attachment.content;
117
+ }
118
+ }
119
+ else if (type === "summary") {
120
+ ev.text = typeof raw.summary === "string" ? raw.summary : stringifyMaybe(raw);
121
+ }
122
+ else if (type === "system") {
123
+ ev.text = typeof raw.content === "string" ? raw.content : stringifyMaybe(raw);
124
+ }
125
+ else if (type === "thinking") {
126
+ ev.thinking = typeof raw.thinking === "string" ? raw.thinking : stringifyMaybe(raw);
127
+ }
128
+ return ev;
129
+ }
130
+ /**
131
+ * Split a single assistant JSONL line into multiple semantic events when
132
+ * the message bundles text + multiple tool_uses. Callers can render each
133
+ * piece in chronological order in the UI.
134
+ */
135
+ export function parseLineMulti(line, sessionId, opts = {}) {
136
+ const primary = parseLine(line, sessionId, opts);
137
+ if (!primary)
138
+ return [];
139
+ let raw;
140
+ try {
141
+ raw = JSON.parse(line.trim());
142
+ }
143
+ catch {
144
+ return [primary];
145
+ }
146
+ if (primary.type === "assistant") {
147
+ const { toolUses } = extractAssistantBlocks(raw.message);
148
+ if (toolUses.length <= 1)
149
+ return [primary];
150
+ const events = [];
151
+ // The primary already includes the first tool_use; emit the rest separately
152
+ events.push(primary);
153
+ for (let i = 1; i < toolUses.length; i++) {
154
+ const tu = toolUses[i];
155
+ events.push({
156
+ sessionId,
157
+ ts: primary.ts + i,
158
+ type: "tool_use",
159
+ toolName: tu.name,
160
+ toolInput: tu.input,
161
+ toolUseId: tu.id,
162
+ uuid: `${primary.uuid ?? ""}-${i}`,
163
+ });
164
+ }
165
+ return events;
166
+ }
167
+ return [primary];
168
+ }
169
+ function normalizeType(t) {
170
+ if (typeof t !== "string")
171
+ return "system";
172
+ if (t === "user" ||
173
+ t === "assistant" ||
174
+ t === "tool_use" ||
175
+ t === "tool_result" ||
176
+ t === "system" ||
177
+ t === "summary" ||
178
+ t === "attachment" ||
179
+ t === "thinking") {
180
+ return t;
181
+ }
182
+ // queue-operation, etc. -> treat as system
183
+ return "system";
184
+ }
185
+ function parseTimestamp(t) {
186
+ if (typeof t === "number")
187
+ return t > 1e12 ? t : t * 1000;
188
+ if (typeof t === "string") {
189
+ const parsed = Date.parse(t);
190
+ return Number.isFinite(parsed) ? parsed : null;
191
+ }
192
+ return null;
193
+ }
194
+ function extractMessageText(msg) {
195
+ if (!msg)
196
+ return undefined;
197
+ if (typeof msg === "string")
198
+ return msg;
199
+ if (typeof msg.content === "string")
200
+ return msg.content;
201
+ if (Array.isArray(msg.content)) {
202
+ const parts = [];
203
+ for (const block of msg.content) {
204
+ if (!block)
205
+ continue;
206
+ if (typeof block === "string")
207
+ parts.push(block);
208
+ else if (block.type === "text" && typeof block.text === "string")
209
+ parts.push(block.text);
210
+ }
211
+ return parts.length ? parts.join("\n") : undefined;
212
+ }
213
+ return undefined;
214
+ }
215
+ /**
216
+ * Pull image attachments out of a message's content tree. Images can appear
217
+ * in two places in Claude Code's JSONL:
218
+ *
219
+ * (1) Direct user paste — top-level user.message.content[]:
220
+ * { type: "image", source: { type: "base64", media_type, data } }
221
+ *
222
+ * (2) Tool result with image — nested inside a tool_result block:
223
+ * { type: "tool_result", content: [ { type: "image", source: {...} } ] }
224
+ *
225
+ * We walk the tree (one level deep is enough — Anthropic doesn't nest deeper)
226
+ * and surface every image we find. The storeImage callback caches the bytes
227
+ * in AttachmentStore and returns a content hash so events stay lightweight.
228
+ */
229
+ function extractImages(msg, storeImage) {
230
+ if (!storeImage || !msg)
231
+ return [];
232
+ const out = [];
233
+ const visit = (blocks) => {
234
+ if (!Array.isArray(blocks))
235
+ return;
236
+ for (const block of blocks) {
237
+ if (!block || typeof block !== "object")
238
+ continue;
239
+ const b = block;
240
+ if (b.type === "image") {
241
+ const src = b.source;
242
+ if (!src || src.type !== "base64" || typeof src.data !== "string" || !src.media_type)
243
+ continue;
244
+ let buf;
245
+ try {
246
+ buf = Buffer.from(src.data, "base64");
247
+ }
248
+ catch {
249
+ continue;
250
+ }
251
+ const hash = storeImage(buf, src.media_type);
252
+ if (!hash)
253
+ continue;
254
+ out.push({ hash, mediaType: src.media_type, sizeBytes: buf.byteLength });
255
+ }
256
+ else if (b.type === "tool_result" && Array.isArray(b.content)) {
257
+ // Recurse into tool_result content — that's where screenshot-tool images live.
258
+ visit(b.content);
259
+ }
260
+ }
261
+ };
262
+ visit(msg.content);
263
+ return out;
264
+ }
265
+ function extractToolResults(msg) {
266
+ if (!msg || !Array.isArray(msg.content))
267
+ return [];
268
+ const results = [];
269
+ for (const block of msg.content) {
270
+ if (block?.type === "tool_result") {
271
+ results.push({
272
+ tool_use_id: String(block.tool_use_id ?? ""),
273
+ text: stringifyMaybe(block.content) ?? "",
274
+ is_error: block.is_error === true,
275
+ });
276
+ }
277
+ }
278
+ return results;
279
+ }
280
+ function extractAssistantBlocks(msg) {
281
+ const out = {
282
+ text: undefined,
283
+ thinking: undefined,
284
+ toolUses: [],
285
+ usage: undefined,
286
+ model: undefined,
287
+ };
288
+ if (!msg)
289
+ return out;
290
+ out.model = typeof msg.model === "string" ? msg.model : undefined;
291
+ out.usage = msg.usage;
292
+ if (typeof msg.content === "string") {
293
+ out.text = msg.content;
294
+ return out;
295
+ }
296
+ if (!Array.isArray(msg.content))
297
+ return out;
298
+ const textParts = [];
299
+ const thinkingParts = [];
300
+ for (const block of msg.content) {
301
+ if (!block)
302
+ continue;
303
+ if (block.type === "text" && typeof block.text === "string")
304
+ textParts.push(block.text);
305
+ else if (block.type === "thinking" && typeof block.thinking === "string")
306
+ thinkingParts.push(block.thinking);
307
+ else if (block.type === "tool_use") {
308
+ out.toolUses.push({
309
+ id: String(block.id ?? ""),
310
+ name: String(block.name ?? "unknown"),
311
+ input: block.input,
312
+ });
313
+ }
314
+ }
315
+ if (textParts.length)
316
+ out.text = textParts.join("\n");
317
+ if (thinkingParts.length)
318
+ out.thinking = thinkingParts.join("\n");
319
+ return out;
320
+ }
321
+ function stringifyMaybe(v) {
322
+ if (v === null || v === undefined)
323
+ return undefined;
324
+ if (typeof v === "string")
325
+ return v;
326
+ if (Array.isArray(v)) {
327
+ const parts = [];
328
+ for (const item of v) {
329
+ if (typeof item === "string")
330
+ parts.push(item);
331
+ else if (item?.type === "text" && typeof item.text === "string")
332
+ parts.push(item.text);
333
+ else
334
+ parts.push(JSON.stringify(item));
335
+ }
336
+ return parts.join("\n");
337
+ }
338
+ try {
339
+ return JSON.stringify(v);
340
+ }
341
+ catch {
342
+ return String(v);
343
+ }
344
+ }
345
+ //# sourceMappingURL=jsonl.js.map