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.
- package/firestore.rules +14 -0
- package/package.json +3 -2
- package/src/auto-approve-engine.js +119 -0
- package/src/build-manager.js +185 -39
- package/src/claude-session-watcher.js +569 -0
- package/src/cli.js +11 -0
- package/src/cloud-mode.js +126 -0
- package/src/distribution.js +192 -0
- package/src/ios-setup.js +281 -0
- package/src/mobile-ai-watcher.js +147 -0
- package/src/session-manager.js +120 -10
- package/src/task-orchestrator.js +119 -0
- package/src/update-checker.js +10 -24
|
@@ -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();
|