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.
- package/package.json +49 -0
- package/src/cli.js +213 -0
- package/src/desktop.js +121 -0
- package/src/firebase.js +46 -0
- package/src/init.js +645 -0
- package/src/logger.js +150 -0
- package/src/project-scanner.js +103 -0
- package/src/screenshot-manager.js +204 -0
- package/src/session-manager.js +1539 -0
- package/src/tunnel-manager.js +201 -0
|
@@ -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
|
+
}
|