forge-remote 2.1.5 → 2.2.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,569 @@
1
+ // Forge Remote — External Claude Code session watcher.
2
+ //
3
+ // Tails ~/.claude/projects/<encoded-project>/<sessionId>.jsonl for projects
4
+ // the user has explicitly enabled mirroring on, and streams new entries
5
+ // into Firestore as `messages` so the mobile app can show what's happening
6
+ // on the desktop without the user having to start the session through the
7
+ // app.
8
+ //
9
+ // Sessions surfaced this way are tagged `type: 'observed'` so the mobile
10
+ // handoff banner offers a "Take Over" button — that path respawns Claude
11
+ // under the relay with `--continue`, killing the original terminal session.
12
+ //
13
+ // Privacy: per-project opt-in. Only paths in `desktops/{id}.mirroredProjects`
14
+ // are watched. Default is empty.
15
+
16
+ import {
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ statSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { promises as fs } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join, resolve } from "node:path";
26
+
27
+ import { FieldValue, getDb } from "./firebase.js";
28
+ import * as log from "./logger.js";
29
+
30
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
31
+ const CURSOR_FILE = join(homedir(), ".forge-remote", "jsonl-cursors.json");
32
+
33
+ // How long to wait without new bytes before flipping a session from
34
+ // `active` → `idle` in Firestore. Claude doesn't emit a clean session-end
35
+ // event so we use silence as a proxy.
36
+ const IDLE_AFTER_MS = 30_000;
37
+
38
+ // Cap individual tool-result payloads written to Firestore. A single Read
39
+ // of a large file can be MBs; Firestore caps documents at 1MB, and the
40
+ // mobile UI doesn't need the full thing — just enough to give context.
41
+ const TOOL_RESULT_TRUNCATE_BYTES = 8 * 1024;
42
+
43
+ // Cap individual user/assistant text messages similarly. Long generated
44
+ // outputs occasionally exceed Firestore limits; truncate with a marker.
45
+ const TEXT_TRUNCATE_BYTES = 64 * 1024;
46
+
47
+ // File-event types in the .jsonl that aren't user-visible — skip silently.
48
+ const SKIP_TYPES = new Set([
49
+ "queue-operation",
50
+ "file-history-snapshot",
51
+ "summary",
52
+ "compact-summary",
53
+ "file-edit-record",
54
+ ]);
55
+
56
+ // Per-file state: read offset + idle timer + bookkeeping for the synthetic
57
+ // session document we created in Firestore for this jsonl.
58
+ const fileState = new Map();
59
+ // Per-desktop config: the set of absolute project paths the user enabled
60
+ // mirroring on. Updated whenever the desktop doc changes.
61
+ let mirroredProjects = new Set();
62
+ let watcher = null;
63
+ let chokidarLib = null;
64
+ let cursorPersistTimer = null;
65
+ let desktopId = null;
66
+
67
+ /**
68
+ * Encode an absolute filesystem path the same way Claude Code does when
69
+ * naming session directories under ~/.claude/projects/. The convention is
70
+ * "replace every `/` with `-`", so `/Users/dan/foo` becomes `-Users-dan-foo`.
71
+ *
72
+ * Note: this collides for paths that *contain* `-` (e.g., `/Users/iron-forge`
73
+ * encodes to the same folder as `/Users/iron/forge`). We accept that — it
74
+ * mirrors Claude's own ambiguity and we only mirror projects the user has
75
+ * explicitly opted in to, so the user already knows which path they meant.
76
+ */
77
+ function encodeProjectPath(absolutePath) {
78
+ return absolutePath.replaceAll("/", "-");
79
+ }
80
+
81
+ function decodeFolderName(folder) {
82
+ return folder.replaceAll("-", "/");
83
+ }
84
+
85
+ function loadCursors() {
86
+ try {
87
+ if (!existsSync(CURSOR_FILE)) return {};
88
+ const raw = readFileSync(CURSOR_FILE, "utf-8");
89
+ return JSON.parse(raw);
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+
95
+ function saveCursorsNow(cursors) {
96
+ try {
97
+ mkdirSync(dirname(CURSOR_FILE), { recursive: true });
98
+ writeFileSync(CURSOR_FILE, JSON.stringify(cursors, null, 2));
99
+ } catch (e) {
100
+ log.warn(`claude-session-watcher: cursor save failed — ${e.message}`);
101
+ }
102
+ }
103
+
104
+ function scheduleCursorPersist() {
105
+ if (cursorPersistTimer) return;
106
+ cursorPersistTimer = setTimeout(() => {
107
+ cursorPersistTimer = null;
108
+ const snapshot = {};
109
+ for (const [path, state] of fileState.entries()) {
110
+ snapshot[path] = { offset: state.offset, sessionId: state.sessionId };
111
+ }
112
+ saveCursorsNow(snapshot);
113
+ }, 2_000);
114
+ // .unref() so this timer doesn't keep the Node event loop alive when
115
+ // SIGINT fires — otherwise Ctrl-C can hang waiting for the persist tick.
116
+ if (typeof cursorPersistTimer.unref === "function") {
117
+ cursorPersistTimer.unref();
118
+ }
119
+ }
120
+
121
+ function truncateBuffer(text, max, label) {
122
+ if (typeof text !== "string") return text;
123
+ if (Buffer.byteLength(text, "utf-8") <= max) return text;
124
+ // Use a generous Buffer slice so multibyte characters at the boundary
125
+ // don't crash JSON serialization. Leave a clear marker for the user.
126
+ const buf = Buffer.from(text, "utf-8").subarray(0, max);
127
+ const decoded = buf.toString("utf-8");
128
+ return `${decoded}\n\n…[${label} truncated; showing first ${max.toLocaleString()} bytes]`;
129
+ }
130
+
131
+ /**
132
+ * Translate a single jsonl entry into the message shape the mobile app
133
+ * expects. Returns null for entries we don't surface (internal state,
134
+ * unknown types, etc.). Returns an array because one assistant entry can
135
+ * fan out into multiple messages (assistant text + N tool calls).
136
+ */
137
+ function eventsToMessages(entry) {
138
+ if (!entry || typeof entry !== "object") return null;
139
+ if (SKIP_TYPES.has(entry.type)) return null;
140
+
141
+ const ts = entry.timestamp ? new Date(entry.timestamp) : new Date();
142
+ const messageId = entry.uuid || entry.id || null;
143
+
144
+ if (entry.type === "user") {
145
+ const content = extractText(entry.message?.content ?? entry.content);
146
+ if (!content) return null;
147
+ return [
148
+ {
149
+ type: "user",
150
+ content: truncateBuffer(content, TEXT_TRUNCATE_BYTES, "prompt"),
151
+ timestamp: ts,
152
+ externalId: messageId,
153
+ },
154
+ ];
155
+ }
156
+
157
+ if (entry.type === "assistant") {
158
+ const blocks = Array.isArray(entry.message?.content)
159
+ ? entry.message.content
160
+ : [];
161
+ const out = [];
162
+ for (const block of blocks) {
163
+ if (!block || typeof block !== "object") continue;
164
+ if (block.type === "text" && typeof block.text === "string") {
165
+ out.push({
166
+ type: "assistant",
167
+ content: truncateBuffer(block.text, TEXT_TRUNCATE_BYTES, "response"),
168
+ timestamp: ts,
169
+ externalId: messageId,
170
+ });
171
+ } else if (block.type === "tool_use") {
172
+ out.push({
173
+ type: "tool_call",
174
+ content: block.name || "tool",
175
+ toolName: block.name || null,
176
+ toolInput: safeStringify(block.input, TOOL_RESULT_TRUNCATE_BYTES),
177
+ toolUseId: block.id || null,
178
+ timestamp: ts,
179
+ externalId: messageId,
180
+ });
181
+ }
182
+ }
183
+ return out.length ? out : null;
184
+ }
185
+
186
+ if (entry.type === "tool_result" || entry.type === "user-tool-result") {
187
+ const content = extractText(entry.message?.content ?? entry.content);
188
+ return [
189
+ {
190
+ type: "tool_result",
191
+ content: truncateBuffer(
192
+ content ?? "",
193
+ TOOL_RESULT_TRUNCATE_BYTES,
194
+ "tool result",
195
+ ),
196
+ toolUseId: entry.tool_use_id || entry.message?.tool_use_id || null,
197
+ isError: Boolean(entry.is_error || entry.message?.is_error),
198
+ timestamp: ts,
199
+ externalId: messageId,
200
+ },
201
+ ];
202
+ }
203
+
204
+ if (entry.type === "system") {
205
+ const content = extractText(entry.message?.content ?? entry.content);
206
+ if (!content) return null;
207
+ return [
208
+ {
209
+ type: "system",
210
+ content: truncateBuffer(content, TEXT_TRUNCATE_BYTES, "system message"),
211
+ timestamp: ts,
212
+ externalId: messageId,
213
+ },
214
+ ];
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Pull a string out of the various shapes Claude emits for `content`. May
222
+ * be a bare string, an array of `{type:'text',text}` blocks, or an array
223
+ * of `{type:'tool_result', content: ...}`. We flatten the text bits and
224
+ * drop the rest.
225
+ */
226
+ function extractText(value) {
227
+ if (typeof value === "string") return value;
228
+ if (!Array.isArray(value)) return null;
229
+ const parts = [];
230
+ for (const block of value) {
231
+ if (!block || typeof block !== "object") continue;
232
+ if (typeof block.text === "string") parts.push(block.text);
233
+ else if (typeof block.content === "string") parts.push(block.content);
234
+ }
235
+ return parts.length ? parts.join("\n") : null;
236
+ }
237
+
238
+ function safeStringify(value, max) {
239
+ try {
240
+ const s = typeof value === "string" ? value : JSON.stringify(value);
241
+ return truncateBuffer(s, max, "tool input");
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Ensure a `sessions/{sessionId}` document exists. First-time call creates
249
+ * one tagged `observed` so the mobile UI shows the takeover banner. The
250
+ * relay's session-manager will not touch this doc (it only manages docs
251
+ * it created itself), so the watcher is the sole writer.
252
+ */
253
+ async function ensureSessionDoc(sessionId, projectPath) {
254
+ const db = getDb();
255
+ const ref = db.collection("sessions").doc(sessionId);
256
+ const snap = await ref.get();
257
+ if (snap.exists) return;
258
+
259
+ // Read the desktop's ownerUid so the session is reachable by the mobile
260
+ // user's queries. If the desktop doc is missing for any reason we fall
261
+ // back to "" — security rules will hide it but the relay can still
262
+ // append messages.
263
+ let ownerUid = "";
264
+ try {
265
+ const desktopDoc = await db.collection("desktops").doc(desktopId).get();
266
+ ownerUid = desktopDoc.exists ? desktopDoc.data()?.ownerUid || "" : "";
267
+ } catch {
268
+ /* best effort */
269
+ }
270
+
271
+ const projectName =
272
+ projectPath.split("/").filter(Boolean).pop() || projectPath;
273
+ await ref.set({
274
+ desktopId,
275
+ ownerUid,
276
+ projectPath,
277
+ projectName,
278
+ type: "observed",
279
+ status: "active",
280
+ model: "sonnet",
281
+ agentType: "claude",
282
+ tokenUsage: { input: 0, output: 0, totalCost: 0 },
283
+ startedAt: FieldValue.serverTimestamp(),
284
+ lastActivity: FieldValue.serverTimestamp(),
285
+ externalSource: "claude-jsonl",
286
+ });
287
+ log.info(
288
+ `claude-session-watcher: surfaced ${sessionId.slice(0, 8)} (${projectName})`,
289
+ );
290
+ }
291
+
292
+ async function appendMessages(sessionId, messages) {
293
+ if (!messages.length) return;
294
+ const db = getDb();
295
+ const batch = db.batch();
296
+ const col = db.collection("sessions").doc(sessionId).collection("messages");
297
+ for (const msg of messages) {
298
+ const docRef = msg.externalId ? col.doc(msg.externalId) : col.doc();
299
+ batch.set(docRef, {
300
+ ...msg,
301
+ timestamp: msg.timestamp instanceof Date ? msg.timestamp : new Date(),
302
+ });
303
+ }
304
+ batch.update(db.collection("sessions").doc(sessionId), {
305
+ lastActivity: FieldValue.serverTimestamp(),
306
+ status: "active",
307
+ });
308
+ await batch.commit();
309
+ }
310
+
311
+ async function markIdle(sessionId) {
312
+ try {
313
+ const db = getDb();
314
+ await db.collection("sessions").doc(sessionId).update({
315
+ status: "idle",
316
+ lastActivity: FieldValue.serverTimestamp(),
317
+ });
318
+ } catch {
319
+ /* best effort */
320
+ }
321
+ }
322
+
323
+ function scheduleIdleCheck(state) {
324
+ if (state.idleTimer) clearTimeout(state.idleTimer);
325
+ state.idleTimer = setTimeout(() => {
326
+ markIdle(state.sessionId);
327
+ }, IDLE_AFTER_MS);
328
+ // Same unref as the cursor timer — never block process exit on this.
329
+ if (typeof state.idleTimer.unref === "function") {
330
+ state.idleTimer.unref();
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Read whatever's been appended to a single .jsonl since the last cursor,
336
+ * translate each line, and flush to Firestore. Updates the in-memory
337
+ * cursor on success and schedules a persist.
338
+ */
339
+ async function processFile(absPath) {
340
+ let state = fileState.get(absPath);
341
+
342
+ // Derive context from the path on first sight.
343
+ if (!state) {
344
+ const parts = absPath.split("/");
345
+ const filename = parts.pop() || "";
346
+ const folder = parts.pop() || "";
347
+ const sessionId = filename.replace(/\.jsonl$/, "");
348
+ const decodedPath = decodeFolderName(folder);
349
+ if (!sessionId || !decodedPath) return;
350
+ state = {
351
+ sessionId,
352
+ projectPath: decodedPath,
353
+ offset: 0,
354
+ idleTimer: null,
355
+ sessionDocReady: false,
356
+ };
357
+ fileState.set(absPath, state);
358
+
359
+ // Restore cursor from disk so we don't replay everything on restart.
360
+ const cursors = loadCursors();
361
+ const saved = cursors[absPath];
362
+ if (saved && typeof saved.offset === "number") {
363
+ state.offset = saved.offset;
364
+ }
365
+ }
366
+
367
+ // Only mirror if the user opted in for this project.
368
+ if (!mirroredProjects.has(state.projectPath)) return;
369
+
370
+ let stat;
371
+ try {
372
+ stat = statSync(absPath);
373
+ } catch {
374
+ return;
375
+ }
376
+ if (stat.size <= state.offset) return;
377
+
378
+ // If the file shrunk (rotation? unlikely for jsonl but possible), reset.
379
+ if (stat.size < state.offset) state.offset = 0;
380
+
381
+ let handle;
382
+ try {
383
+ handle = await fs.open(absPath, "r");
384
+ const buffer = Buffer.alloc(stat.size - state.offset);
385
+ await handle.read(buffer, 0, buffer.length, state.offset);
386
+ await handle.close();
387
+ handle = null;
388
+
389
+ const text = buffer.toString("utf-8");
390
+ // Only consume up to the last complete line. Partial line stays for
391
+ // the next pass — Claude streams writes, so half-lines are common.
392
+ const lastNewline = text.lastIndexOf("\n");
393
+ if (lastNewline === -1) return;
394
+
395
+ const consumed = text.slice(0, lastNewline + 1);
396
+ const lines = consumed.split("\n").filter(Boolean);
397
+
398
+ if (!state.sessionDocReady) {
399
+ await ensureSessionDoc(state.sessionId, state.projectPath);
400
+ state.sessionDocReady = true;
401
+ }
402
+
403
+ const messages = [];
404
+ for (const line of lines) {
405
+ let entry;
406
+ try {
407
+ entry = JSON.parse(line);
408
+ } catch {
409
+ continue;
410
+ }
411
+ const out = eventsToMessages(entry);
412
+ if (out) messages.push(...out);
413
+ }
414
+
415
+ if (messages.length) {
416
+ await appendMessages(state.sessionId, messages);
417
+ }
418
+
419
+ state.offset += Buffer.byteLength(consumed, "utf-8");
420
+ scheduleCursorPersist();
421
+ scheduleIdleCheck(state);
422
+ } catch (e) {
423
+ log.warn(`claude-session-watcher: ${absPath} — ${e.message}`);
424
+ if (handle) await handle.close().catch(() => {});
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Pull the current `mirroredProjects` list from the desktop doc and
430
+ * subscribe to changes so toggling a project on/off in the mobile app
431
+ * takes effect within seconds.
432
+ */
433
+ function watchDesktopConfig(id) {
434
+ const db = getDb();
435
+ return db
436
+ .collection("desktops")
437
+ .doc(id)
438
+ .onSnapshot((snap) => {
439
+ const list = snap.exists ? snap.data()?.mirroredProjects || [] : [];
440
+ const next = new Set(
441
+ Array.isArray(list)
442
+ ? list.filter((p) => typeof p === "string" && p.length > 0)
443
+ : [],
444
+ );
445
+ // Resolve to absolute paths defensively — the mobile app stores
446
+ // them as-is from the project list, but normalize to be safe.
447
+ const normalized = new Set();
448
+ for (const p of next) {
449
+ try {
450
+ normalized.add(resolve(p));
451
+ } catch {
452
+ normalized.add(p);
453
+ }
454
+ }
455
+ mirroredProjects = normalized;
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Boot the watcher. Idempotent — calling twice is a no-op.
461
+ */
462
+ export async function startClaudeSessionWatcher(id) {
463
+ if (watcher) return;
464
+ desktopId = id;
465
+
466
+ if (!existsSync(CLAUDE_PROJECTS_DIR)) {
467
+ log.info(
468
+ "claude-session-watcher: ~/.claude/projects not found — skipping " +
469
+ "(no external sessions to mirror)",
470
+ );
471
+ return;
472
+ }
473
+
474
+ // chokidar is optional. If the relay package didn't pull it in (older
475
+ // installs, or trimmed deps), fall back to a poll-based watcher built
476
+ // on Node's fs.watch + a 2s interval scan.
477
+ try {
478
+ chokidarLib = (await import("chokidar")).default;
479
+ } catch {
480
+ chokidarLib = null;
481
+ }
482
+
483
+ // Subscribe to per-desktop mirror config.
484
+ const unsubConfig = watchDesktopConfig(desktopId);
485
+
486
+ if (chokidarLib) {
487
+ watcher = chokidarLib.watch(`${CLAUDE_PROJECTS_DIR}/**/*.jsonl`, {
488
+ ignoreInitial: false,
489
+ // Polling falls back gracefully on filesystems where inotify isn't
490
+ // reliable (network mounts, SSHFS). The native option is faster.
491
+ usePolling: false,
492
+ awaitWriteFinish: false,
493
+ });
494
+ watcher.on("add", (path) => processFile(path));
495
+ watcher.on("change", (path) => processFile(path));
496
+ watcher.unsubConfig = unsubConfig;
497
+ log.info("claude-session-watcher: started (chokidar)");
498
+ } else {
499
+ // Polling fallback: scan every 2 seconds.
500
+ log.info(
501
+ "claude-session-watcher: started (polling, install chokidar for inotify)",
502
+ );
503
+ const pollInterval = setInterval(async () => {
504
+ try {
505
+ const projects = await fs.readdir(CLAUDE_PROJECTS_DIR);
506
+ for (const proj of projects) {
507
+ const projDir = join(CLAUDE_PROJECTS_DIR, proj);
508
+ let entries;
509
+ try {
510
+ entries = await fs.readdir(projDir);
511
+ } catch {
512
+ continue;
513
+ }
514
+ for (const f of entries) {
515
+ if (!f.endsWith(".jsonl")) continue;
516
+ await processFile(join(projDir, f));
517
+ }
518
+ }
519
+ } catch (e) {
520
+ log.warn(`claude-session-watcher: scan error — ${e.message}`);
521
+ }
522
+ }, 2_000);
523
+ watcher = { close: () => clearInterval(pollInterval), unsubConfig };
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Tear down the watcher and cancel any pending idle timers. Called from
529
+ * the relay's SIGINT handler.
530
+ *
531
+ * Bounded — chokidar's close() can stall on macOS when there are many
532
+ * watched files. We race it against a 1-second timeout so Ctrl-C never
533
+ * gets stuck waiting on the watcher to wind down.
534
+ */
535
+ export async function stopClaudeSessionWatcher() {
536
+ if (!watcher) return;
537
+ // Cancel the Firestore config listener immediately — that one's cheap.
538
+ try {
539
+ if (watcher.unsubConfig) watcher.unsubConfig();
540
+ } catch {
541
+ /* best effort */
542
+ }
543
+ // Persist cursors synchronously up front so a hung close() doesn't lose
544
+ // the last few offsets we accumulated this session.
545
+ if (cursorPersistTimer) clearTimeout(cursorPersistTimer);
546
+ const snapshot = {};
547
+ for (const [path, state] of fileState.entries()) {
548
+ snapshot[path] = { offset: state.offset, sessionId: state.sessionId };
549
+ if (state.idleTimer) clearTimeout(state.idleTimer);
550
+ }
551
+ if (Object.keys(snapshot).length) saveCursorsNow(snapshot);
552
+
553
+ // Race chokidar.close() against a 1s deadline. If it doesn't return in
554
+ // time, give up — the process is exiting anyway and the OS will reclaim
555
+ // the file watches.
556
+ const closePromise = (async () => {
557
+ try {
558
+ if (typeof watcher.close === "function") await watcher.close();
559
+ } catch {
560
+ /* best effort */
561
+ }
562
+ })();
563
+ const timeout = new Promise((resolve) => {
564
+ const t = setTimeout(resolve, 1_000);
565
+ if (typeof t.unref === "function") t.unref();
566
+ });
567
+ await Promise.race([closePromise, timeout]);
568
+ watcher = null;
569
+ }
package/src/cli.js CHANGED
@@ -32,6 +32,10 @@ import { stopAllCaptures } from "./screenshot-manager.js";
32
32
  import { scanProjects } from "./project-scanner.js";
33
33
  import { startWebhookServer, stopWebhookServer } from "./webhook-server.js";
34
34
  import { watchWebhookConfigs, stopWatching } from "./webhook-watcher.js";
35
+ import {
36
+ startClaudeSessionWatcher,
37
+ stopClaudeSessionWatcher,
38
+ } from "./claude-session-watcher.js";
35
39
  import * as log from "./logger.js";
36
40
  import { checkForUpdate } from "./update-checker.js";
37
41
  import { deployFirestoreRules } from "./google-auth.js";
@@ -200,6 +204,12 @@ program
200
204
  log.info(`Webhook server ready at ${webhookServer.tunnelUrl}`);
201
205
  }
202
206
 
207
+ // Mirror externally-spawned Claude Code sessions (those launched in
208
+ // a terminal, not via the mobile app). Reads from ~/.claude/projects
209
+ // and surfaces messages to Firestore for projects the user opted in
210
+ // to mirror. Per-project opt-in lives on the desktop doc.
211
+ await startClaudeSessionWatcher(desktopId);
212
+
203
213
  // Print startup banner.
204
214
  log.banner(hostname(), getPlatformName(), desktopId, projects.length);
205
215
 
@@ -224,6 +234,7 @@ program
224
234
  try {
225
235
  stopWatching();
226
236
  await stopWebhookServer();
237
+ await stopClaudeSessionWatcher();
227
238
  await shutdownAllSessions();
228
239
  stopAllTunnels();
229
240
  stopAllCaptures();