forge-remote 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,1539 @@
1
+ // Forge Remote Relay — Desktop Agent for Forge Remote
2
+ // Copyright (c) 2025-2026 Iron Forge Apps
3
+ // Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
4
+ // AGPL-3.0 License — See LICENSE
5
+
6
+ import { getDb, FieldValue } from "./firebase.js";
7
+ import { spawn, execSync } from "child_process";
8
+ import * as log from "./logger.js";
9
+ import { startTunnel, stopTunnel } from "./tunnel-manager.js";
10
+ import {
11
+ startCapturing,
12
+ stopCapturing,
13
+ hasActiveSimulator,
14
+ } from "./screenshot-manager.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Resolve the user's shell environment so spawned processes inherit PATH,
18
+ // API keys (ANTHROPIC_API_KEY), etc. Node's default `process.env` may be
19
+ // stripped down when launched from certain contexts (launchd, cron, etc.).
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Build an env for spawning Claude processes.
24
+ *
25
+ * Strategy: start with process.env (preserves auth, PATH, etc.) then merge
26
+ * any extra vars from the user's shell profile (picks up PATH additions,
27
+ * ANTHROPIC_API_KEY, etc.). Finally, strip any CLAUDECODE / CLAUDE_CODE_*
28
+ * vars that block nested launches.
29
+ */
30
+ function buildSpawnEnv() {
31
+ // Start with current runtime env — this has auth context.
32
+ const env = { ...process.env };
33
+
34
+ // Try to merge shell profile vars (picks up PATH changes, API keys, etc.).
35
+ try {
36
+ const shell = process.env.SHELL || "/bin/zsh";
37
+ const raw = execSync(`${shell} -ilc env`, {
38
+ timeout: 5000,
39
+ encoding: "utf-8",
40
+ stdio: ["pipe", "pipe", "pipe"],
41
+ });
42
+ for (const line of raw.split("\n")) {
43
+ const idx = line.indexOf("=");
44
+ if (idx > 0) {
45
+ const key = line.slice(0, idx);
46
+ // Only add vars that aren't already set — don't overwrite runtime env.
47
+ if (!(key in env)) {
48
+ env[key] = line.slice(idx + 1);
49
+ }
50
+ }
51
+ }
52
+ } catch {
53
+ log.warn("Could not resolve user shell profile — using process.env only");
54
+ }
55
+
56
+ // Remove ALL env vars that block Claude from spawning.
57
+ const BLOCKED_PREFIXES = ["CLAUDECODE", "CLAUDE_CODE"];
58
+ for (const key of Object.keys(env)) {
59
+ if (BLOCKED_PREFIXES.some((prefix) => key.startsWith(prefix))) {
60
+ log.info(`Removing blocking env var: ${key}`);
61
+ delete env[key];
62
+ }
63
+ }
64
+
65
+ return env;
66
+ }
67
+
68
+ const shellEnv = buildSpawnEnv();
69
+
70
+ // Verify claude is findable.
71
+ function resolveClaudePath() {
72
+ try {
73
+ const p = execSync("which claude", {
74
+ encoding: "utf-8",
75
+ env: shellEnv,
76
+ timeout: 5000,
77
+ }).trim();
78
+ if (p) {
79
+ log.success(`Claude CLI found at: ${p}`);
80
+ return p;
81
+ }
82
+ } catch {
83
+ // Fall through.
84
+ }
85
+ log.warn("Could not find 'claude' in PATH — will try spawning directly");
86
+ return "claude";
87
+ }
88
+
89
+ const claudeBinary = resolveClaudePath();
90
+
91
+ /**
92
+ * Tracks active sessions.
93
+ *
94
+ * Each entry stores the session config (cwd, model) and optionally a
95
+ * running Claude process. Between turns the process is null and the
96
+ * session status is "idle". A new process is spawned for each prompt
97
+ * using `--continue` so Claude picks up the conversation history.
98
+ */
99
+ const activeSessions = new Map();
100
+
101
+ /**
102
+ * Maximum number of concurrent sessions this relay will run.
103
+ * Can be overridden via the FORGE_MAX_SESSIONS environment variable.
104
+ */
105
+ const MAX_SESSIONS = parseInt(process.env.FORGE_MAX_SESSIONS, 10) || 3;
106
+
107
+ /**
108
+ * Returns the count of real sessions (excluding command-watcher sentinel keys).
109
+ */
110
+ function getActiveSessionCount() {
111
+ let count = 0;
112
+ for (const key of activeSessions.keys()) {
113
+ if (!key.startsWith("cmd-watcher-")) count++;
114
+ }
115
+ return count;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Command listeners
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export function listenForCommands(desktopId) {
123
+ const db = getDb();
124
+
125
+ // Desktop-level commands (start_session, etc.).
126
+ db.collection("desktops")
127
+ .doc(desktopId)
128
+ .collection("commands")
129
+ .where("status", "==", "pending")
130
+ .onSnapshot((snap) => {
131
+ for (const change of snap.docChanges()) {
132
+ if (change.type === "added") {
133
+ handleDesktopCommand(desktopId, change.doc);
134
+ }
135
+ }
136
+ });
137
+
138
+ // Watch for existing sessions → subscribe to their commands.
139
+ db.collection("sessions")
140
+ .where("desktopId", "==", desktopId)
141
+ .onSnapshot((snap) => {
142
+ for (const doc of snap.docs) {
143
+ watchSessionCommands(doc.id);
144
+ }
145
+ });
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Desktop commands
150
+ // ---------------------------------------------------------------------------
151
+
152
+ async function handleDesktopCommand(desktopId, commandDoc) {
153
+ const data = commandDoc.data();
154
+ const db = getDb();
155
+ const cmdRef = db
156
+ .collection("desktops")
157
+ .doc(desktopId)
158
+ .collection("commands")
159
+ .doc(commandDoc.id);
160
+
161
+ log.command(data.type, data.payload?.projectPath || "");
162
+ await cmdRef.update({ status: "processing" });
163
+
164
+ try {
165
+ switch (data.type) {
166
+ case "start_session":
167
+ await startNewSession(desktopId, data.payload);
168
+ break;
169
+ default:
170
+ log.warn(`Unknown desktop command type: ${data.type}`);
171
+ }
172
+ await cmdRef.update({ status: "completed" });
173
+ } catch (e) {
174
+ log.error(`Desktop command failed: ${e.message}`);
175
+ await cmdRef.update({ status: "failed", error: e.message });
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Session commands
181
+ // ---------------------------------------------------------------------------
182
+
183
+ function watchSessionCommands(sessionId) {
184
+ if (activeSessions.has(`cmd-watcher-${sessionId}`)) return;
185
+ activeSessions.set(`cmd-watcher-${sessionId}`, true);
186
+
187
+ const db = getDb();
188
+ db.collection("sessions")
189
+ .doc(sessionId)
190
+ .collection("commands")
191
+ .where("status", "==", "pending")
192
+ .onSnapshot((snap) => {
193
+ for (const change of snap.docChanges()) {
194
+ if (change.type === "added") {
195
+ handleSessionCommand(sessionId, change.doc);
196
+ }
197
+ }
198
+ });
199
+ }
200
+
201
+ async function handleSessionCommand(sessionId, commandDoc) {
202
+ const data = commandDoc.data();
203
+ const db = getDb();
204
+ const cmdRef = db
205
+ .collection("sessions")
206
+ .doc(sessionId)
207
+ .collection("commands")
208
+ .doc(commandDoc.id);
209
+
210
+ await cmdRef.update({ status: "processing" });
211
+
212
+ try {
213
+ switch (data.type) {
214
+ case "send_prompt":
215
+ log.command(
216
+ "send_prompt",
217
+ `"${(data.payload?.prompt || "").slice(0, 60)}..."`,
218
+ );
219
+ await sendFollowUpPrompt(sessionId, data.payload?.prompt);
220
+ break;
221
+ case "stop_session":
222
+ log.command("stop_session", sessionId.slice(0, 8));
223
+ await stopSession(sessionId);
224
+ break;
225
+ case "kill_session":
226
+ log.command("kill_session", sessionId.slice(0, 8));
227
+ await killSession(sessionId);
228
+ break;
229
+ default:
230
+ log.warn(`Unknown session command type: ${data.type}`);
231
+ }
232
+ await cmdRef.update({ status: "completed" });
233
+ } catch (e) {
234
+ log.error(`Session command failed: ${e.message}`);
235
+ await cmdRef.update({ status: "failed", error: e.message });
236
+ }
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Start a new session
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const MODEL_DISPLAY_NAMES = {
244
+ sonnet: "Claude Sonnet 4.6",
245
+ opus: "Claude Opus 4.6",
246
+ haiku: "Claude Haiku 4.5",
247
+ };
248
+
249
+ function modelDisplayName(model) {
250
+ return (
251
+ MODEL_DISPLAY_NAMES[model?.toLowerCase()] || model || "Claude Sonnet 4.6"
252
+ );
253
+ }
254
+
255
+ export async function startNewSession(desktopId, payload) {
256
+ // Enforce session limit.
257
+ const currentCount = getActiveSessionCount();
258
+ if (currentCount >= MAX_SESSIONS) {
259
+ throw new Error(
260
+ `Session limit reached (${currentCount}/${MAX_SESSIONS}). ` +
261
+ `End an existing session before starting a new one. ` +
262
+ `Set FORGE_MAX_SESSIONS env var to increase the limit.`,
263
+ );
264
+ }
265
+
266
+ const { prompt, projectPath, model } = payload || {};
267
+ const db = getDb();
268
+ const resolvedModel = model || "sonnet";
269
+ const resolvedPath = projectPath || process.cwd();
270
+ const projectName = resolvedPath.split("/").pop();
271
+
272
+ // Create session document.
273
+ const sessionRef = db.collection("sessions").doc();
274
+ const sessionId = sessionRef.id;
275
+
276
+ await sessionRef.set({
277
+ desktopId,
278
+ ownerUid: "",
279
+ projectPath: resolvedPath,
280
+ projectName,
281
+ type: "managed",
282
+ status: "active",
283
+ model: resolvedModel,
284
+ tokenUsage: { input: 0, output: 0, totalCost: 0 },
285
+ startedAt: FieldValue.serverTimestamp(),
286
+ lastActivity: FieldValue.serverTimestamp(),
287
+ });
288
+
289
+ // Copy ownerUid from desktop.
290
+ const desktopDoc = await db.collection("desktops").doc(desktopId).get();
291
+ if (desktopDoc.exists) {
292
+ await sessionRef.update({ ownerUid: desktopDoc.data().ownerUid });
293
+ }
294
+
295
+ // Register session in active sessions map (no process yet).
296
+ activeSessions.set(sessionId, {
297
+ process: null,
298
+ desktopId,
299
+ projectPath: resolvedPath,
300
+ model: resolvedModel,
301
+ startTime: Date.now(),
302
+ messageCount: 0,
303
+ toolCallCount: 0,
304
+ isFirstPrompt: true,
305
+ lastToolCall: null, // Last tool_use block (for permission requests)
306
+ permissionNeeded: false, // True when Claude reports permission denial
307
+ permissionWatcher: null, // Firestore unsubscribe for permission doc
308
+ });
309
+
310
+ // Desktop terminal banner.
311
+ log.sessionStarted({
312
+ sessionId,
313
+ project: resolvedPath,
314
+ model: modelDisplayName(resolvedModel),
315
+ prompt,
316
+ });
317
+
318
+ // System message in Firestore.
319
+ const configSummary = [
320
+ `Session started on ${projectName}`,
321
+ `Model: ${modelDisplayName(resolvedModel)}`,
322
+ `Project: ${resolvedPath}`,
323
+ ].join(" · ");
324
+
325
+ await db.collection("sessions").doc(sessionId).collection("messages").add({
326
+ type: "system",
327
+ content: configSummary,
328
+ timestamp: FieldValue.serverTimestamp(),
329
+ });
330
+
331
+ // Store the initial prompt as a user message.
332
+ if (prompt) {
333
+ await db.collection("sessions").doc(sessionId).collection("messages").add({
334
+ type: "user",
335
+ content: prompt,
336
+ timestamp: FieldValue.serverTimestamp(),
337
+ });
338
+ }
339
+
340
+ watchSessionCommands(sessionId);
341
+
342
+ // Run the initial prompt.
343
+ if (prompt) {
344
+ await runClaudeProcess(sessionId, prompt);
345
+ }
346
+
347
+ return sessionId;
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Send a follow-up prompt (spawns a new process with --continue)
352
+ // ---------------------------------------------------------------------------
353
+
354
+ async function sendFollowUpPrompt(sessionId, prompt) {
355
+ const session = activeSessions.get(sessionId);
356
+ if (!session) {
357
+ throw new Error("Session not found. It may have ended.");
358
+ }
359
+
360
+ if (session.process) {
361
+ throw new Error(
362
+ "Claude is still processing. Wait for the current turn to finish.",
363
+ );
364
+ }
365
+
366
+ // Cancel any pending permission watcher — user is overriding with a prompt.
367
+ if (session.permissionWatcher) {
368
+ session.permissionWatcher();
369
+ session.permissionWatcher = null;
370
+ }
371
+ session.permissionNeeded = false;
372
+
373
+ // Store the user's message in Firestore.
374
+ const db = getDb();
375
+ await db.collection("sessions").doc(sessionId).collection("messages").add({
376
+ type: "user",
377
+ content: prompt,
378
+ timestamp: FieldValue.serverTimestamp(),
379
+ });
380
+
381
+ log.session(
382
+ sessionId,
383
+ `Follow-up prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`,
384
+ );
385
+
386
+ // Run Claude with --continue to pick up conversation history.
387
+ await runClaudeProcess(sessionId, prompt);
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Core: spawn a Claude process for a single turn
392
+ // ---------------------------------------------------------------------------
393
+
394
+ async function runClaudeProcess(sessionId, prompt) {
395
+ const session = activeSessions.get(sessionId);
396
+ if (!session) return;
397
+
398
+ // Reset permission state for this new turn.
399
+ session.permissionNeeded = false;
400
+ session.lastToolCall = null;
401
+
402
+ const db = getDb();
403
+ const sessionRef = db.collection("sessions").doc(sessionId);
404
+
405
+ // Mark session as active.
406
+ await sessionRef.update({
407
+ status: "active",
408
+ lastActivity: FieldValue.serverTimestamp(),
409
+ });
410
+
411
+ // Build args.
412
+ const args = ["--output-format", "stream-json", "--verbose", "-p"];
413
+ if (session.model) args.push("--model", session.model);
414
+
415
+ // Use --continue for follow-up prompts to maintain conversation context.
416
+ if (!session.isFirstPrompt) {
417
+ args.push("--continue");
418
+ }
419
+ session.isFirstPrompt = false;
420
+
421
+ args.push(prompt);
422
+
423
+ log.session(sessionId, `Spawning: ${claudeBinary} ${args.join(" ")}`);
424
+
425
+ const claudeProcess = spawn(claudeBinary, args, {
426
+ cwd: session.projectPath,
427
+ env: shellEnv,
428
+ stdio: ["pipe", "pipe", "pipe"],
429
+ });
430
+
431
+ session.process = claudeProcess;
432
+
433
+ // Close stdin immediately — `-p` mode reads the prompt from args, not stdin.
434
+ // Leaving stdin open can cause Claude CLI to hang waiting for input.
435
+ claudeProcess.stdin.end();
436
+
437
+ log.session(sessionId, `Process started — PID ${claudeProcess.pid}`);
438
+
439
+ // Watchdog: warn if no output received. Opus is much slower to start.
440
+ const watchdogMs = session.model === "opus" ? 45_000 : 20_000;
441
+ const killAfterMs = 120_000; // Auto-kill if completely stuck after 2 min.
442
+ let receivedOutput = false;
443
+
444
+ const watchdog = setTimeout(() => {
445
+ if (!receivedOutput) {
446
+ // Check if process is still alive.
447
+ let alive = false;
448
+ try {
449
+ process.kill(claudeProcess.pid, 0); // Signal 0 = check existence.
450
+ alive = true;
451
+ } catch {
452
+ alive = false;
453
+ }
454
+ log.warn(
455
+ `[${sessionId.slice(0, 8)}] No output from Claude after ${watchdogMs / 1000}s — PID ${claudeProcess.pid} ${alive ? "still running" : "DEAD"}`,
456
+ );
457
+ }
458
+ }, watchdogMs);
459
+
460
+ // Hard kill if process never produces output.
461
+ const killTimer = setTimeout(async () => {
462
+ if (!receivedOutput) {
463
+ log.error(
464
+ `[${sessionId.slice(0, 8)}] No output after ${killAfterMs / 1000}s — auto-killing stuck process (PID ${claudeProcess.pid})`,
465
+ );
466
+ claudeProcess.kill("SIGKILL");
467
+
468
+ await sessionRef.update({
469
+ status: "error",
470
+ lastActivity: FieldValue.serverTimestamp(),
471
+ errorMessage:
472
+ "Process timed out — no output received. Claude CLI may need re-authentication or the model may be unavailable.",
473
+ });
474
+
475
+ await db
476
+ .collection("sessions")
477
+ .doc(sessionId)
478
+ .collection("messages")
479
+ .add({
480
+ type: "system",
481
+ content:
482
+ "Session timed out — Claude produced no output. Try restarting the relay or using a different model.",
483
+ timestamp: FieldValue.serverTimestamp(),
484
+ });
485
+ }
486
+ }, killAfterMs);
487
+
488
+ // ── Parse stdout (stream-json) ──
489
+ let stdoutBuffer = "";
490
+
491
+ claudeProcess.stdout.on("data", async (data) => {
492
+ if (!receivedOutput) {
493
+ receivedOutput = true;
494
+ clearTimeout(watchdog);
495
+ clearTimeout(killTimer);
496
+ log.session(sessionId, "First output received from Claude");
497
+ }
498
+
499
+ stdoutBuffer += data.toString();
500
+ const lines = stdoutBuffer.split("\n");
501
+ stdoutBuffer = lines.pop();
502
+
503
+ for (const line of lines) {
504
+ if (!line.trim()) continue;
505
+ try {
506
+ const event = JSON.parse(line);
507
+ await handleStreamEvent(sessionId, sessionRef, event);
508
+ } catch {
509
+ if (line.trim()) {
510
+ const text = line.trim();
511
+ log.claudeOutput(sessionId, text);
512
+
513
+ // Detect auth / login errors that come as plain text.
514
+ if (/not logged in|please run.*login/i.test(text)) {
515
+ log.error(
516
+ `[${sessionId.slice(0, 8)}] Claude CLI auth error: ${text}`,
517
+ );
518
+ await db
519
+ .collection("sessions")
520
+ .doc(sessionId)
521
+ .collection("messages")
522
+ .add({
523
+ type: "system",
524
+ content: `Auth error: ${text}. Run "claude /login" in your terminal, then restart the relay.`,
525
+ timestamp: FieldValue.serverTimestamp(),
526
+ });
527
+ } else {
528
+ await storeAssistantMessage(sessionId, text);
529
+ }
530
+ }
531
+ }
532
+ }
533
+ });
534
+
535
+ // ── Stderr ──
536
+ let stderrBuffer = "";
537
+
538
+ claudeProcess.stderr.on("data", async (data) => {
539
+ if (!receivedOutput) {
540
+ receivedOutput = true;
541
+ clearTimeout(watchdog);
542
+ clearTimeout(killTimer);
543
+ }
544
+ const raw = data.toString();
545
+ stderrBuffer += raw;
546
+ const lines = stderrBuffer.split("\n");
547
+ stderrBuffer = lines.pop();
548
+
549
+ for (const line of lines) {
550
+ if (!line.trim()) continue;
551
+ log.warn(`[stderr] ${line.trim()}`);
552
+ await db
553
+ .collection("sessions")
554
+ .doc(sessionId)
555
+ .collection("messages")
556
+ .add({
557
+ type: "system",
558
+ content: line.trim(),
559
+ timestamp: FieldValue.serverTimestamp(),
560
+ });
561
+ // Dev servers often print to stderr.
562
+ await detectAndTunnelDevServer(sessionId, line);
563
+ // Native app build output often goes to stderr too.
564
+ detectAndStartScreenshots(sessionId, line);
565
+ }
566
+ });
567
+
568
+ // ── Process exit — mark session idle (not completed) so user can continue ──
569
+ claudeProcess.on("close", async (code, signal) => {
570
+ clearTimeout(watchdog);
571
+ clearTimeout(killTimer);
572
+ log.session(
573
+ sessionId,
574
+ `Process exited — code: ${code}, signal: ${signal}, PID: ${claudeProcess.pid}`,
575
+ );
576
+
577
+ // Flush any remaining data in buffers.
578
+ if (stdoutBuffer.trim()) {
579
+ log.claudeOutput(sessionId, `[flush] ${stdoutBuffer.trim()}`);
580
+ }
581
+ if (stderrBuffer.trim()) {
582
+ log.warn(`[stderr/flush] ${stderrBuffer.trim()}`);
583
+ await db
584
+ .collection("sessions")
585
+ .doc(sessionId)
586
+ .collection("messages")
587
+ .add({
588
+ type: "system",
589
+ content: stderrBuffer.trim(),
590
+ timestamp: FieldValue.serverTimestamp(),
591
+ });
592
+ }
593
+
594
+ const sess = activeSessions.get(sessionId);
595
+ if (sess) sess.process = null; // Clear process so follow-ups can spawn.
596
+
597
+ if (code === 0) {
598
+ if (sess?.permissionNeeded && sess?.lastToolCall) {
599
+ // Claude was denied a tool call — create a permission request and wait.
600
+ const permDocId = await createPermissionRequest(
601
+ sessionId,
602
+ sess.lastToolCall,
603
+ );
604
+ await sessionRef.update({
605
+ status: "waiting_permission",
606
+ lastActivity: FieldValue.serverTimestamp(),
607
+ });
608
+ watchPermissionDecision(sessionId, permDocId);
609
+ log.session(
610
+ sessionId,
611
+ "Permission needed — waiting for mobile approval",
612
+ );
613
+ } else {
614
+ // Turn completed successfully — session goes idle, awaiting next prompt.
615
+ await sessionRef.update({
616
+ status: "idle",
617
+ lastActivity: FieldValue.serverTimestamp(),
618
+ });
619
+
620
+ await db
621
+ .collection("sessions")
622
+ .doc(sessionId)
623
+ .collection("messages")
624
+ .add({
625
+ type: "system",
626
+ content:
627
+ "Claude finished — send another message or end the session.",
628
+ timestamp: FieldValue.serverTimestamp(),
629
+ });
630
+
631
+ log.session(
632
+ sessionId,
633
+ "Turn complete — session idle, waiting for input",
634
+ );
635
+ }
636
+ } else {
637
+ // Error — end the session.
638
+ const duration = sess
639
+ ? Math.round((Date.now() - sess.startTime) / 1000)
640
+ : 0;
641
+ const durationStr = formatDuration(duration);
642
+ const toolCount = sess?.toolCallCount || 0;
643
+ const msgCount = sess?.messageCount || 0;
644
+
645
+ activeSessions.delete(sessionId);
646
+
647
+ await sessionRef.update({
648
+ status: "error",
649
+ lastActivity: FieldValue.serverTimestamp(),
650
+ durationSeconds: duration,
651
+ errorMessage: `Process exited with code ${code}`,
652
+ });
653
+
654
+ await db
655
+ .collection("sessions")
656
+ .doc(sessionId)
657
+ .collection("messages")
658
+ .add({
659
+ type: "system",
660
+ content: `Session ended with error (code ${code}) · Duration: ${durationStr}`,
661
+ timestamp: FieldValue.serverTimestamp(),
662
+ });
663
+
664
+ log.sessionEnded({
665
+ sessionId,
666
+ status: "error",
667
+ duration: durationStr,
668
+ toolCalls: toolCount,
669
+ messages: msgCount,
670
+ });
671
+ }
672
+ });
673
+
674
+ claudeProcess.on("error", async (err) => {
675
+ log.error(`Failed to start Claude process: ${err.message}`);
676
+ const sess = activeSessions.get(sessionId);
677
+ if (sess) sess.process = null;
678
+
679
+ await sessionRef.update({
680
+ status: "error",
681
+ lastActivity: FieldValue.serverTimestamp(),
682
+ errorMessage: `Failed to start: ${err.message}`,
683
+ });
684
+
685
+ await db
686
+ .collection("sessions")
687
+ .doc(sessionId)
688
+ .collection("messages")
689
+ .add({
690
+ type: "system",
691
+ content: `Failed to start Claude: ${err.message}. Is Claude Code CLI installed?`,
692
+ timestamp: FieldValue.serverTimestamp(),
693
+ });
694
+ });
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Stop a session
699
+ // ---------------------------------------------------------------------------
700
+
701
+ async function stopSession(sessionId) {
702
+ const session = activeSessions.get(sessionId);
703
+
704
+ // Kill running process if any.
705
+ if (session?.process) {
706
+ session.process.kill("SIGTERM");
707
+ }
708
+
709
+ // Cancel permission watcher if active.
710
+ if (session?.permissionWatcher) {
711
+ session.permissionWatcher();
712
+ session.permissionWatcher = null;
713
+ }
714
+
715
+ // Stop any active tunnel and screenshot capture.
716
+ await stopTunnel(sessionId);
717
+ tunneledPorts.delete(sessionId);
718
+ stopCapturing(sessionId);
719
+ capturingSessions.delete(sessionId);
720
+
721
+ const db = getDb();
722
+ const sessionRef = db.collection("sessions").doc(sessionId);
723
+ const duration = session
724
+ ? Math.round((Date.now() - session.startTime) / 1000)
725
+ : 0;
726
+ const durationStr = formatDuration(duration);
727
+
728
+ activeSessions.delete(sessionId);
729
+
730
+ await sessionRef.update({
731
+ status: "completed",
732
+ lastActivity: FieldValue.serverTimestamp(),
733
+ durationSeconds: duration,
734
+ });
735
+
736
+ await db
737
+ .collection("sessions")
738
+ .doc(sessionId)
739
+ .collection("messages")
740
+ .add({
741
+ type: "system",
742
+ content: `Session ended by user · Duration: ${durationStr}`,
743
+ timestamp: FieldValue.serverTimestamp(),
744
+ });
745
+
746
+ log.sessionEnded({
747
+ sessionId,
748
+ status: "completed",
749
+ duration: durationStr,
750
+ toolCalls: session?.toolCallCount || 0,
751
+ messages: session?.messageCount || 0,
752
+ });
753
+
754
+ log.session(sessionId, "Session stopped by mobile user");
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // Kill a session (force — SIGKILL if SIGTERM doesn't work)
759
+ // ---------------------------------------------------------------------------
760
+
761
+ async function killSession(sessionId) {
762
+ const session = activeSessions.get(sessionId);
763
+
764
+ // Cancel permission watcher if active.
765
+ if (session?.permissionWatcher) {
766
+ session.permissionWatcher();
767
+ session.permissionWatcher = null;
768
+ }
769
+
770
+ // Stop any active tunnel and screenshot capture.
771
+ await stopTunnel(sessionId);
772
+ tunneledPorts.delete(sessionId);
773
+ stopCapturing(sessionId);
774
+ capturingSessions.delete(sessionId);
775
+
776
+ // Force-kill running process.
777
+ if (session?.process) {
778
+ log.warn(
779
+ `Force-killing Claude process for session ${sessionId.slice(0, 8)}`,
780
+ );
781
+ session.process.kill("SIGKILL");
782
+ }
783
+
784
+ const db = getDb();
785
+ const sessionRef = db.collection("sessions").doc(sessionId);
786
+ const duration = session
787
+ ? Math.round((Date.now() - session.startTime) / 1000)
788
+ : 0;
789
+ const durationStr = formatDuration(duration);
790
+
791
+ activeSessions.delete(sessionId);
792
+
793
+ await sessionRef.update({
794
+ status: "completed",
795
+ lastActivity: FieldValue.serverTimestamp(),
796
+ durationSeconds: duration,
797
+ });
798
+
799
+ await db
800
+ .collection("sessions")
801
+ .doc(sessionId)
802
+ .collection("messages")
803
+ .add({
804
+ type: "system",
805
+ content: `Session killed by user · Duration: ${durationStr}`,
806
+ timestamp: FieldValue.serverTimestamp(),
807
+ });
808
+
809
+ log.sessionEnded({
810
+ sessionId,
811
+ status: "completed",
812
+ duration: durationStr,
813
+ toolCalls: session?.toolCallCount || 0,
814
+ messages: session?.messageCount || 0,
815
+ });
816
+
817
+ log.session(sessionId, "Session force-killed by mobile user");
818
+ }
819
+
820
+ // ---------------------------------------------------------------------------
821
+ // Stream-JSON event handler
822
+ // ---------------------------------------------------------------------------
823
+
824
+ async function handleStreamEvent(sessionId, sessionRef, event) {
825
+ const db = getDb();
826
+ const session = activeSessions.get(sessionId);
827
+
828
+ // Result event — end of a turn (token usage).
829
+ if (event.type === "result") {
830
+ const tokenUsage = {
831
+ input: event.input_tokens || 0,
832
+ output: event.output_tokens || 0,
833
+ totalCost: event.cost_usd || 0,
834
+ };
835
+
836
+ await sessionRef.update({
837
+ tokenUsage,
838
+ lastActivity: FieldValue.serverTimestamp(),
839
+ });
840
+
841
+ if (session) session.tokenUsage = tokenUsage;
842
+
843
+ log.session(
844
+ sessionId,
845
+ `Tokens: ${tokenUsage.input} in / ${tokenUsage.output} out ($${tokenUsage.totalCost.toFixed(4)})`,
846
+ );
847
+ return;
848
+ }
849
+
850
+ // Assistant message.
851
+ if (event.type === "assistant") {
852
+ const contentBlocks = event.message?.content || [];
853
+
854
+ for (const block of contentBlocks) {
855
+ if (block.type === "text" && block.text?.trim()) {
856
+ log.claudeOutput(sessionId, block.text);
857
+ await storeAssistantMessage(sessionId, block.text);
858
+ // Check for dev server URLs in Claude's text output.
859
+ await detectAndTunnelDevServer(sessionId, block.text);
860
+ // Check for native app build/run patterns.
861
+ detectAndStartScreenshots(sessionId, block.text);
862
+ }
863
+
864
+ if (block.type === "tool_use") {
865
+ log.toolCall(sessionId, block.name, block.input);
866
+ await storeToolCall(sessionId, block);
867
+ // Track the last tool call for permission detection.
868
+ if (session) {
869
+ session.lastToolCall = {
870
+ name: block.name,
871
+ input: block.input,
872
+ id: block.id,
873
+ };
874
+ }
875
+ // Check tool output for dev server URLs (e.g., Bash running npm start).
876
+ const toolStr =
877
+ typeof block.input === "string"
878
+ ? block.input
879
+ : JSON.stringify(block.input || {});
880
+ await detectAndTunnelDevServer(sessionId, toolStr);
881
+ // Check for native app build/run patterns.
882
+ detectAndStartScreenshots(sessionId, toolStr);
883
+ }
884
+ }
885
+
886
+ await sessionRef.update({ lastActivity: FieldValue.serverTimestamp() });
887
+ return;
888
+ }
889
+
890
+ // Content block start (tool use).
891
+ if (event.type === "content_block_start") {
892
+ const block = event.content_block;
893
+ if (block?.type === "tool_use") {
894
+ log.toolCall(sessionId, block.name, block.input);
895
+ await storeToolCall(sessionId, block);
896
+ // Track for permission detection.
897
+ if (session) {
898
+ session.lastToolCall = {
899
+ name: block.name,
900
+ input: block.input,
901
+ id: block.id,
902
+ };
903
+ }
904
+ }
905
+ return;
906
+ }
907
+
908
+ // Content block stop — may carry the final accumulated tool input.
909
+ if (event.type === "content_block_stop") {
910
+ // Some stream formats include the final content block here; not always
911
+ // actionable but we log it for debugging.
912
+ return;
913
+ }
914
+
915
+ // Tool result — contains the output from a tool execution.
916
+ if (event.type === "tool_result") {
917
+ const toolUseId = event.tool_use_id;
918
+ const isError = event.is_error === true;
919
+
920
+ // Extract result text from the content blocks.
921
+ let resultText = "";
922
+ if (typeof event.content === "string") {
923
+ resultText = event.content;
924
+ } else if (Array.isArray(event.content)) {
925
+ resultText = event.content
926
+ .map((block) => {
927
+ if (block.type === "text") return block.text;
928
+ return JSON.stringify(block);
929
+ })
930
+ .join("\n");
931
+ } else if (event.output) {
932
+ resultText =
933
+ typeof event.output === "string"
934
+ ? event.output
935
+ : JSON.stringify(event.output);
936
+ }
937
+
938
+ if (toolUseId) {
939
+ await updateToolCallResult(sessionId, toolUseId, resultText, !isError);
940
+
941
+ // Store result as a message for the chat view.
942
+ if (resultText) {
943
+ await db
944
+ .collection("sessions")
945
+ .doc(sessionId)
946
+ .collection("messages")
947
+ .add({
948
+ type: "tool_result",
949
+ content: resultText.slice(0, 5000), // Cap at 5KB for Firestore.
950
+ toolUseId,
951
+ isError,
952
+ timestamp: FieldValue.serverTimestamp(),
953
+ });
954
+ }
955
+ }
956
+
957
+ // Check tool result output for dev server URLs.
958
+ if (resultText) {
959
+ await detectAndTunnelDevServer(sessionId, resultText);
960
+ detectAndStartScreenshots(sessionId, resultText);
961
+ }
962
+
963
+ await sessionRef.update({ lastActivity: FieldValue.serverTimestamp() });
964
+ return;
965
+ }
966
+
967
+ // System init.
968
+ if (event.type === "system" && event.subtype === "init") {
969
+ log.session(sessionId, "Claude Code initialized");
970
+ return;
971
+ }
972
+ }
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // Firestore write helpers
976
+ // ---------------------------------------------------------------------------
977
+
978
+ // Patterns that indicate Claude was denied permission for a tool call.
979
+ const PERMISSION_PATTERNS = [
980
+ /need your approval/i,
981
+ /permission (?:required|denied|blocked)/i,
982
+ /could you approve/i,
983
+ /keeps? getting blocked/i,
984
+ /permission system/i,
985
+ /being blocked by/i,
986
+ /approve the command/i,
987
+ /blocked by.+permission/i,
988
+ /can'?t (?:execute|run).+(?:permission|approval)/i,
989
+ ];
990
+
991
+ async function storeAssistantMessage(sessionId, text) {
992
+ const db = getDb();
993
+ const session = activeSessions.get(sessionId);
994
+ if (session) session.messageCount = (session.messageCount || 0) + 1;
995
+
996
+ await db.collection("sessions").doc(sessionId).collection("messages").add({
997
+ type: "assistant",
998
+ content: text,
999
+ timestamp: FieldValue.serverTimestamp(),
1000
+ });
1001
+
1002
+ // Check if Claude's message indicates a permission denial.
1003
+ if (session?.lastToolCall && !session.permissionNeeded) {
1004
+ for (const pattern of PERMISSION_PATTERNS) {
1005
+ if (pattern.test(text)) {
1006
+ session.permissionNeeded = true;
1007
+ log.info(
1008
+ `[${sessionId.slice(0, 8)}] Permission denial detected for ${session.lastToolCall.name}`,
1009
+ );
1010
+ break;
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * Maps Claude tool_use block IDs to Firestore toolCall doc IDs so we can
1018
+ * update tool call documents with their results when they arrive later in
1019
+ * the stream (via content_block_stop or tool_result events).
1020
+ *
1021
+ * Key: `${sessionId}:${block.id}` — Value: Firestore doc ID
1022
+ */
1023
+ const toolCallDocIds = new Map();
1024
+
1025
+ async function storeToolCall(sessionId, block) {
1026
+ const db = getDb();
1027
+ const session = activeSessions.get(sessionId);
1028
+ if (session) session.toolCallCount = (session.toolCallCount || 0) + 1;
1029
+
1030
+ const toolName = block.name || "unknown";
1031
+ const toolInput =
1032
+ typeof block.input === "string"
1033
+ ? block.input
1034
+ : JSON.stringify(block.input || {});
1035
+
1036
+ const docRef = await db
1037
+ .collection("sessions")
1038
+ .doc(sessionId)
1039
+ .collection("toolCalls")
1040
+ .add({
1041
+ toolName,
1042
+ toolInput,
1043
+ toolUseId: block.id || null,
1044
+ result: null,
1045
+ timestamp: FieldValue.serverTimestamp(),
1046
+ success: true,
1047
+ });
1048
+
1049
+ // Track the mapping so we can update this doc when the result arrives.
1050
+ if (block.id) {
1051
+ toolCallDocIds.set(`${sessionId}:${block.id}`, docRef.id);
1052
+ }
1053
+
1054
+ await db
1055
+ .collection("sessions")
1056
+ .doc(sessionId)
1057
+ .collection("messages")
1058
+ .add({
1059
+ type: "tool_call",
1060
+ content: `${toolName}: ${toolInput.slice(0, 200)}`,
1061
+ toolName,
1062
+ timestamp: FieldValue.serverTimestamp(),
1063
+ });
1064
+ }
1065
+
1066
+ /**
1067
+ * Update a previously stored tool call with its result.
1068
+ */
1069
+ async function updateToolCallResult(sessionId, toolUseId, resultText, success) {
1070
+ const key = `${sessionId}:${toolUseId}`;
1071
+ const firestoreDocId = toolCallDocIds.get(key);
1072
+ if (!firestoreDocId) {
1073
+ log.warn(
1074
+ `[${sessionId.slice(0, 8)}] No tool call doc found for tool_use ID ${toolUseId}`,
1075
+ );
1076
+ return;
1077
+ }
1078
+
1079
+ const db = getDb();
1080
+ await db
1081
+ .collection("sessions")
1082
+ .doc(sessionId)
1083
+ .collection("toolCalls")
1084
+ .doc(firestoreDocId)
1085
+ .update({
1086
+ result: resultText,
1087
+ success: success !== false,
1088
+ });
1089
+
1090
+ // Clean up the mapping entry.
1091
+ toolCallDocIds.delete(key);
1092
+
1093
+ log.session(
1094
+ sessionId,
1095
+ `Tool result stored (${toolUseId?.slice(0, 12)}): ${resultText?.slice(0, 80)}...`,
1096
+ );
1097
+ }
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // Dev server URL detection + tunnel creation
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ // Patterns that indicate a native mobile app is being built/run.
1104
+ // When matched, we start periodic simulator screenshot capture.
1105
+ const NATIVE_APP_PATTERNS = [
1106
+ /flutter\s+run/i,
1107
+ /react-native\s+run/i,
1108
+ /npx\s+expo\s+start/i,
1109
+ /npx\s+react-native\s+start/i,
1110
+ /Launching\s+lib\/main\.dart/i,
1111
+ /Running\s+Xcode\s+build/i,
1112
+ /Installing.*\.app/i,
1113
+ /BUILD\s+SUCCEEDED/i,
1114
+ ];
1115
+
1116
+ // Sessions that already have screenshot capture running.
1117
+ const capturingSessions = new Set();
1118
+
1119
+ function detectAndStartScreenshots(sessionId, text) {
1120
+ if (!text || capturingSessions.has(sessionId)) return;
1121
+
1122
+ for (const pattern of NATIVE_APP_PATTERNS) {
1123
+ if (pattern.test(text)) {
1124
+ // Double-check that a simulator/emulator is actually running.
1125
+ if (hasActiveSimulator()) {
1126
+ capturingSessions.add(sessionId);
1127
+ startCapturing(sessionId);
1128
+ return;
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ // Matches localhost URLs from common dev server output.
1135
+ const LOCALHOST_PATTERNS = [
1136
+ // Full URLs: http://localhost:3000, https://127.0.0.1:5173
1137
+ /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
1138
+ // Server startup messages: "ready on http://localhost:3000"
1139
+ /(?:ready on|listening on|server running at|started at|Local:)\s*https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i,
1140
+ // Server startup with port only: "listening on :3000"
1141
+ /(?:ready on|listening on|server running at|started at|Local:)\s*:?(\d{3,5})/i,
1142
+ // Bare localhost:port references (catches Claude's text like "running on localhost:3000")
1143
+ /(?:localhost|127\.0\.0\.1):(\d{3,5})/i,
1144
+ // "port 3000" in context of dev server discussion
1145
+ /(?:running|serving|available|dev server)\b.*?\bport\s+(\d{3,5})/i,
1146
+ ];
1147
+
1148
+ // Ports we've already tunneled for a session (avoid duplicates).
1149
+ const tunneledPorts = new Map(); // sessionId → Set<port>
1150
+
1151
+ async function detectAndTunnelDevServer(sessionId, text) {
1152
+ if (!text) return;
1153
+
1154
+ for (const pattern of LOCALHOST_PATTERNS) {
1155
+ const match = text.match(pattern);
1156
+ if (match) {
1157
+ const port = parseInt(match[1], 10);
1158
+ if (port < 1024 || port > 65535) continue;
1159
+
1160
+ // Skip if we already tunneled this port for this session.
1161
+ const ported = tunneledPorts.get(sessionId) || new Set();
1162
+ if (ported.has(port)) return;
1163
+ ported.add(port);
1164
+ tunneledPorts.set(sessionId, ported);
1165
+
1166
+ log.info(`Dev server detected on port ${port} — creating tunnel...`);
1167
+
1168
+ const tunnelUrl = await startTunnel(sessionId, port);
1169
+ if (tunnelUrl) {
1170
+ // Store tunnel URL in Firestore session doc.
1171
+ const db = getDb();
1172
+ await db.collection("sessions").doc(sessionId).update({
1173
+ devServerUrl: tunnelUrl,
1174
+ devServerPort: port,
1175
+ });
1176
+
1177
+ // Notify in messages.
1178
+ await db
1179
+ .collection("sessions")
1180
+ .doc(sessionId)
1181
+ .collection("messages")
1182
+ .add({
1183
+ type: "system",
1184
+ content: `Live preview available — dev server on port ${port} tunneled to ${tunnelUrl}`,
1185
+ timestamp: FieldValue.serverTimestamp(),
1186
+ });
1187
+
1188
+ log.success(
1189
+ `[${sessionId.slice(0, 8)}] Preview: localhost:${port} → ${tunnelUrl}`,
1190
+ );
1191
+ }
1192
+ return; // Only tunnel the first detected port.
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ // ---------------------------------------------------------------------------
1198
+ // Graceful shutdown — mark all active sessions as completed
1199
+ // ---------------------------------------------------------------------------
1200
+
1201
+ export async function shutdownAllSessions() {
1202
+ const db = getDb();
1203
+ const sessionIds = [...activeSessions.keys()].filter(
1204
+ (k) => !k.startsWith("cmd-watcher-"),
1205
+ );
1206
+
1207
+ if (sessionIds.length === 0) return;
1208
+
1209
+ log.info(`Shutting down ${sessionIds.length} active session(s)...`);
1210
+
1211
+ for (const sessionId of sessionIds) {
1212
+ const session = activeSessions.get(sessionId);
1213
+ if (!session) continue;
1214
+
1215
+ // Kill running process.
1216
+ if (session.process) {
1217
+ session.process.kill("SIGTERM");
1218
+ }
1219
+
1220
+ // Cancel permission watcher.
1221
+ if (session.permissionWatcher) {
1222
+ session.permissionWatcher();
1223
+ session.permissionWatcher = null;
1224
+ }
1225
+
1226
+ const duration = Math.round((Date.now() - session.startTime) / 1000);
1227
+
1228
+ try {
1229
+ await db.collection("sessions").doc(sessionId).update({
1230
+ status: "completed",
1231
+ lastActivity: FieldValue.serverTimestamp(),
1232
+ durationSeconds: duration,
1233
+ });
1234
+
1235
+ await db
1236
+ .collection("sessions")
1237
+ .doc(sessionId)
1238
+ .collection("messages")
1239
+ .add({
1240
+ type: "system",
1241
+ content: "Session ended — relay was shut down.",
1242
+ timestamp: FieldValue.serverTimestamp(),
1243
+ });
1244
+
1245
+ log.session(sessionId, "Marked completed (relay shutdown)");
1246
+ } catch (e) {
1247
+ log.error(
1248
+ `Failed to clean up session ${sessionId.slice(0, 8)}: ${e.message}`,
1249
+ );
1250
+ }
1251
+
1252
+ activeSessions.delete(sessionId);
1253
+ }
1254
+ }
1255
+
1256
+ // ---------------------------------------------------------------------------
1257
+ // Startup cleanup — mark orphaned sessions from previous runs as interrupted
1258
+ // ---------------------------------------------------------------------------
1259
+
1260
+ /**
1261
+ * On startup, check Firestore for sessions that were active for this desktop
1262
+ * but no longer have a running Claude process (because the relay restarted).
1263
+ * Mark them as `interrupted` so the mobile app shows the correct status.
1264
+ */
1265
+ export async function cleanupOrphanedSessions(desktopId) {
1266
+ const db = getDb();
1267
+
1268
+ // Query for sessions that were active, idle, or waiting_permission.
1269
+ const activeStatuses = ["active", "idle", "waiting_permission"];
1270
+ const snapshot = await db
1271
+ .collection("sessions")
1272
+ .where("desktopId", "==", desktopId)
1273
+ .where("status", "in", activeStatuses)
1274
+ .get();
1275
+
1276
+ if (snapshot.empty) {
1277
+ log.info("No orphaned sessions found from previous runs");
1278
+ return;
1279
+ }
1280
+
1281
+ log.info(
1282
+ `Found ${snapshot.size} orphaned session(s) from a previous run — marking as interrupted`,
1283
+ );
1284
+
1285
+ for (const doc of snapshot.docs) {
1286
+ const sessionId = doc.id;
1287
+ const data = doc.data();
1288
+
1289
+ try {
1290
+ await db.collection("sessions").doc(sessionId).update({
1291
+ status: "interrupted",
1292
+ lastActivity: FieldValue.serverTimestamp(),
1293
+ interruptedAt: FieldValue.serverTimestamp(),
1294
+ interruptReason: "relay_restart",
1295
+ });
1296
+
1297
+ await db
1298
+ .collection("sessions")
1299
+ .doc(sessionId)
1300
+ .collection("messages")
1301
+ .add({
1302
+ type: "system",
1303
+ content:
1304
+ "Session interrupted — the relay was restarted. " +
1305
+ "This session cannot be resumed because the Claude process is gone. " +
1306
+ "Start a new session to continue working.",
1307
+ timestamp: FieldValue.serverTimestamp(),
1308
+ });
1309
+
1310
+ log.session(
1311
+ sessionId,
1312
+ `Marked as interrupted (was ${data.status}, project: ${data.projectName || data.projectPath || "unknown"})`,
1313
+ );
1314
+ } catch (e) {
1315
+ log.error(
1316
+ `Failed to clean up orphaned session ${sessionId.slice(0, 8)}: ${e.message}`,
1317
+ );
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ // ---------------------------------------------------------------------------
1323
+ // Permission handling
1324
+ // ---------------------------------------------------------------------------
1325
+
1326
+ /**
1327
+ * Build human-readable text for the permission modal.
1328
+ */
1329
+ function buildPermissionDisplayText(toolName, input) {
1330
+ const inp = typeof input === "object" ? input || {} : {};
1331
+ switch (toolName) {
1332
+ case "Bash":
1333
+ return inp.command || JSON.stringify(inp);
1334
+ case "Write":
1335
+ case "Edit":
1336
+ case "MultiEdit":
1337
+ return `${inp.file_path || inp.path || "unknown file"}`;
1338
+ case "Read":
1339
+ return inp.file_path || inp.path || JSON.stringify(inp);
1340
+ default:
1341
+ return typeof input === "string" ? input : JSON.stringify(inp, null, 2);
1342
+ }
1343
+ }
1344
+
1345
+ /**
1346
+ * Create a permission request doc in Firestore.
1347
+ * Returns the doc ID so we can watch it for decisions.
1348
+ */
1349
+ async function createPermissionRequest(sessionId, toolCall) {
1350
+ const db = getDb();
1351
+ const displayText = buildPermissionDisplayText(toolCall.name, toolCall.input);
1352
+ const toolInput =
1353
+ typeof toolCall.input === "object" ? toolCall.input || {} : {};
1354
+
1355
+ const timeoutAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minute timeout
1356
+
1357
+ const docRef = await db
1358
+ .collection("sessions")
1359
+ .doc(sessionId)
1360
+ .collection("permissions")
1361
+ .add({
1362
+ toolName: toolCall.name,
1363
+ toolInput,
1364
+ displayText,
1365
+ status: "pending",
1366
+ requestedAt: FieldValue.serverTimestamp(),
1367
+ timeoutAt,
1368
+ });
1369
+
1370
+ // System message to show in the chat.
1371
+ await db
1372
+ .collection("sessions")
1373
+ .doc(sessionId)
1374
+ .collection("messages")
1375
+ .add({
1376
+ type: "system",
1377
+ content: `Permission required — Claude wants to use ${toolCall.name}: ${displayText.slice(0, 200)}`,
1378
+ timestamp: FieldValue.serverTimestamp(),
1379
+ });
1380
+
1381
+ log.info(
1382
+ `[${sessionId.slice(0, 8)}] Permission request created: ${toolCall.name} — ${displayText.slice(0, 80)}`,
1383
+ );
1384
+
1385
+ return docRef.id;
1386
+ }
1387
+
1388
+ /**
1389
+ * Watch a permission doc for the user's decision.
1390
+ */
1391
+ function watchPermissionDecision(sessionId, permDocId) {
1392
+ const db = getDb();
1393
+ const session = activeSessions.get(sessionId);
1394
+ if (!session) return;
1395
+
1396
+ const docRef = db
1397
+ .collection("sessions")
1398
+ .doc(sessionId)
1399
+ .collection("permissions")
1400
+ .doc(permDocId);
1401
+
1402
+ const unsubscribe = docRef.onSnapshot(async (doc) => {
1403
+ if (!doc.exists) return;
1404
+ const data = doc.data();
1405
+
1406
+ if (data.status === "approved" || data.status === "always_allow") {
1407
+ unsubscribe();
1408
+ if (session.permissionWatcher === unsubscribe) {
1409
+ session.permissionWatcher = null;
1410
+ }
1411
+ await handlePermissionApproved(sessionId, data);
1412
+ } else if (data.status === "denied") {
1413
+ unsubscribe();
1414
+ if (session.permissionWatcher === unsubscribe) {
1415
+ session.permissionWatcher = null;
1416
+ }
1417
+ await handlePermissionDenied(sessionId);
1418
+ }
1419
+ });
1420
+
1421
+ // Store unsubscribe for cleanup.
1422
+ session.permissionWatcher = unsubscribe;
1423
+
1424
+ // Auto-deny after 5 minutes.
1425
+ setTimeout(
1426
+ async () => {
1427
+ const sess = activeSessions.get(sessionId);
1428
+ if (sess?.permissionNeeded) {
1429
+ log.warn(
1430
+ `[${sessionId.slice(0, 8)}] Permission timed out — auto-denying`,
1431
+ );
1432
+ // Update Firestore doc to denied.
1433
+ try {
1434
+ await docRef.update({
1435
+ status: "denied",
1436
+ decidedAt: FieldValue.serverTimestamp(),
1437
+ decidedBy: "timeout",
1438
+ });
1439
+ } catch {
1440
+ // Doc may already be updated.
1441
+ }
1442
+ }
1443
+ },
1444
+ 5 * 60 * 1000,
1445
+ );
1446
+ }
1447
+
1448
+ /**
1449
+ * Handle permission approval.
1450
+ *
1451
+ * When a user approves a tool call, we send a follow-up prompt to Claude
1452
+ * asking it to proceed with the approved tool. We do NOT execute commands
1453
+ * directly on the relay — Claude's own CLI handles tool execution.
1454
+ *
1455
+ * This ensures all tool calls flow through Claude's safety and context
1456
+ * tracking, and avoids duplicating execution logic or bypassing the CLI's
1457
+ * permission system.
1458
+ */
1459
+ async function handlePermissionApproved(sessionId, permData) {
1460
+ const session = activeSessions.get(sessionId);
1461
+ if (!session) return;
1462
+
1463
+ const toolCall = session.lastToolCall;
1464
+ session.permissionNeeded = false;
1465
+
1466
+ log.success(
1467
+ `[${sessionId.slice(0, 8)}] Permission approved for ${permData.toolName}`,
1468
+ );
1469
+
1470
+ const db = getDb();
1471
+
1472
+ // Build a descriptive summary for the approval message.
1473
+ const toolInput =
1474
+ typeof toolCall.input === "object" ? toolCall.input || {} : toolCall.input;
1475
+ const inputSummary =
1476
+ toolCall.name === "Bash"
1477
+ ? (typeof toolInput === "object" ? toolInput.command : toolInput) || ""
1478
+ : typeof toolInput === "string"
1479
+ ? toolInput
1480
+ : JSON.stringify(toolInput).slice(0, 200);
1481
+
1482
+ await db
1483
+ .collection("sessions")
1484
+ .doc(sessionId)
1485
+ .collection("messages")
1486
+ .add({
1487
+ type: "system",
1488
+ content: `Permission approved — asking Claude to retry ${toolCall.name}${inputSummary ? `: ${inputSummary.slice(0, 120)}` : ""}.`,
1489
+ timestamp: FieldValue.serverTimestamp(),
1490
+ });
1491
+
1492
+ // Send a follow-up prompt to Claude so it re-executes the tool itself.
1493
+ const prompt =
1494
+ `The user has approved the ${toolCall.name} tool call` +
1495
+ (inputSummary ? ` (${inputSummary.slice(0, 200)})` : "") +
1496
+ `. Please go ahead and execute it now, then continue with the original task.`;
1497
+
1498
+ await runClaudeProcess(sessionId, prompt);
1499
+ }
1500
+
1501
+ /**
1502
+ * Handle permission denial — mark session idle.
1503
+ */
1504
+ async function handlePermissionDenied(sessionId) {
1505
+ const session = activeSessions.get(sessionId);
1506
+ if (!session) return;
1507
+
1508
+ session.permissionNeeded = false;
1509
+ session.lastToolCall = null;
1510
+
1511
+ log.info(`[${sessionId.slice(0, 8)}] Permission denied`);
1512
+
1513
+ const db = getDb();
1514
+ await db.collection("sessions").doc(sessionId).update({
1515
+ status: "idle",
1516
+ lastActivity: FieldValue.serverTimestamp(),
1517
+ });
1518
+
1519
+ await db.collection("sessions").doc(sessionId).collection("messages").add({
1520
+ type: "system",
1521
+ content:
1522
+ "Permission denied — command was not executed. Send another message or end the session.",
1523
+ timestamp: FieldValue.serverTimestamp(),
1524
+ });
1525
+ }
1526
+
1527
+ // ---------------------------------------------------------------------------
1528
+ // Helpers
1529
+ // ---------------------------------------------------------------------------
1530
+
1531
+ function formatDuration(seconds) {
1532
+ if (seconds < 60) return `${seconds}s`;
1533
+ const mins = Math.floor(seconds / 60);
1534
+ const secs = seconds % 60;
1535
+ if (mins < 60) return `${mins}m ${secs}s`;
1536
+ const hours = Math.floor(mins / 60);
1537
+ const remainMins = mins % 60;
1538
+ return `${hours}h ${remainMins}m`;
1539
+ }