forge-remote 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/firestore.rules +8 -0
- package/package.json +2 -9
- package/src/cli.js +62 -0
- package/src/firebase.js +5 -0
- package/src/init.js +1 -1
- package/src/notifications.js +202 -0
- package/src/session-manager.js +71 -3
- package/src/update-checker.js +72 -0
- package/src/webhook-server.js +714 -0
- package/src/webhook-watcher.js +111 -0
package/firestore.rules
CHANGED
|
@@ -37,6 +37,14 @@ service cloud.firestore {
|
|
|
37
37
|
allow create: if isSignedIn();
|
|
38
38
|
allow update: if isSignedIn();
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
// ---- Webhooks (subcollection) ----
|
|
42
|
+
match /webhooks/{webhookId} {
|
|
43
|
+
allow read: if isSignedIn();
|
|
44
|
+
allow create: if isSignedIn();
|
|
45
|
+
allow update: if isSignedIn();
|
|
46
|
+
allow delete: if isSignedIn();
|
|
47
|
+
}
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
// ---- Sessions ----
|
package/package.json
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
|
|
8
|
-
"
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "https://github.com/IronForgeApps/forge-remote.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/IronForgeApps/forge-remote/issues"
|
|
14
|
-
},
|
|
15
|
-
"homepage": "https://github.com/IronForgeApps/forge-remote#readme",
|
|
8
|
+
"homepage": "https://forgeremote.com",
|
|
16
9
|
"engines": {
|
|
17
10
|
"node": ">=18.0.0"
|
|
18
11
|
},
|
package/src/cli.js
CHANGED
|
@@ -30,7 +30,10 @@ import {
|
|
|
30
30
|
import { stopAllTunnels } from "./tunnel-manager.js";
|
|
31
31
|
import { stopAllCaptures } from "./screenshot-manager.js";
|
|
32
32
|
import { scanProjects } from "./project-scanner.js";
|
|
33
|
+
import { startWebhookServer, stopWebhookServer } from "./webhook-server.js";
|
|
34
|
+
import { watchWebhookConfigs, stopWatching } from "./webhook-watcher.js";
|
|
33
35
|
import * as log from "./logger.js";
|
|
36
|
+
import { checkForUpdate } from "./update-checker.js";
|
|
34
37
|
|
|
35
38
|
program
|
|
36
39
|
.name("forge-remote")
|
|
@@ -126,6 +129,7 @@ program
|
|
|
126
129
|
"Start the desktop relay (register, heartbeat, listen for commands)",
|
|
127
130
|
)
|
|
128
131
|
.action(async () => {
|
|
132
|
+
await checkForUpdate();
|
|
129
133
|
initFirebase();
|
|
130
134
|
const desktopId = await registerDesktop();
|
|
131
135
|
|
|
@@ -140,6 +144,13 @@ program
|
|
|
140
144
|
|
|
141
145
|
listenForCommands(desktopId);
|
|
142
146
|
|
|
147
|
+
// Start webhook server with tunnel for external integrations.
|
|
148
|
+
const webhookServer = await startWebhookServer(desktopId);
|
|
149
|
+
if (webhookServer) {
|
|
150
|
+
watchWebhookConfigs(desktopId, webhookServer.tunnelUrl);
|
|
151
|
+
log.info(`Webhook server ready at ${webhookServer.tunnelUrl}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
// Print startup banner.
|
|
144
155
|
log.banner(hostname(), getPlatformName(), desktopId, projects.length);
|
|
145
156
|
|
|
@@ -162,6 +173,8 @@ program
|
|
|
162
173
|
}, 5000);
|
|
163
174
|
|
|
164
175
|
try {
|
|
176
|
+
stopWatching();
|
|
177
|
+
await stopWebhookServer();
|
|
165
178
|
await shutdownAllSessions();
|
|
166
179
|
stopAllTunnels();
|
|
167
180
|
stopAllCaptures();
|
|
@@ -183,6 +196,7 @@ program
|
|
|
183
196
|
.command("pair")
|
|
184
197
|
.description("Generate a pairing QR code for the mobile app")
|
|
185
198
|
.action(async () => {
|
|
199
|
+
await checkForUpdate();
|
|
186
200
|
initFirebase();
|
|
187
201
|
const desktopId = await registerDesktop();
|
|
188
202
|
|
|
@@ -232,4 +246,52 @@ program
|
|
|
232
246
|
await runInit({ projectId: opts.projectId });
|
|
233
247
|
});
|
|
234
248
|
|
|
249
|
+
program
|
|
250
|
+
.command("set-pro <uid>")
|
|
251
|
+
.description(
|
|
252
|
+
"Grant permanent Pro access to a Firebase Auth UID via custom claims",
|
|
253
|
+
)
|
|
254
|
+
.action(async (uid) => {
|
|
255
|
+
initFirebase();
|
|
256
|
+
|
|
257
|
+
const { getAuth } = await import("firebase-admin/auth");
|
|
258
|
+
const auth = getAuth();
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// Verify the UID exists.
|
|
262
|
+
const user = await auth.getUser(uid);
|
|
263
|
+
log.info(
|
|
264
|
+
`Found user: ${user.uid} (created ${user.metadata.creationTime})`,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Set the custom claim.
|
|
268
|
+
await auth.setCustomUserClaims(uid, { pro: true });
|
|
269
|
+
log.success(`Set pro: true custom claim on UID ${uid}`);
|
|
270
|
+
log.info(
|
|
271
|
+
"The user must re-open the app (or force-refresh their token) for this to take effect.",
|
|
272
|
+
);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
log.error(`Failed to set custom claims: ${e.message}`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
program
|
|
280
|
+
.command("remove-pro <uid>")
|
|
281
|
+
.description("Revoke permanent Pro access from a Firebase Auth UID")
|
|
282
|
+
.action(async (uid) => {
|
|
283
|
+
initFirebase();
|
|
284
|
+
|
|
285
|
+
const { getAuth } = await import("firebase-admin/auth");
|
|
286
|
+
const auth = getAuth();
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await auth.setCustomUserClaims(uid, { pro: false });
|
|
290
|
+
log.success(`Removed pro claim from UID ${uid}`);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
log.error(`Failed to remove custom claims: ${e.message}`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
235
297
|
program.parse();
|
package/src/firebase.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { initializeApp, cert } from "firebase-admin/app";
|
|
2
2
|
import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
3
|
+
import { getMessaging as _getMessaging } from "firebase-admin/messaging";
|
|
3
4
|
import { readFileSync, existsSync } from "fs";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { homedir } from "os";
|
|
@@ -43,4 +44,8 @@ export function getDb() {
|
|
|
43
44
|
return db;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function getMessaging() {
|
|
48
|
+
return _getMessaging();
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export { FieldValue, Timestamp };
|
package/src/init.js
CHANGED
|
@@ -1039,7 +1039,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
1039
1039
|
|
|
1040
1040
|
console.log(
|
|
1041
1041
|
chalk.dim(
|
|
1042
|
-
" Love Forge Remote? Support us: https://
|
|
1042
|
+
" Love Forge Remote? Support us: https://buy.stripe.com/7sY5kDcL7e792rs06E5J606\n",
|
|
1043
1043
|
),
|
|
1044
1044
|
);
|
|
1045
1045
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Forge Remote Relay — Push Notification Module
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
+
|
|
5
|
+
import { getDb, getMessaging } from "./firebase.js";
|
|
6
|
+
import * as log from "./logger.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// FCM token retrieval
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read all FCM tokens for a desktop from Firestore.
|
|
14
|
+
* Tokens are stored at: desktops/{desktopId}/fcmTokens/{platform}
|
|
15
|
+
* Returns an array of { token, platform, docRef } objects.
|
|
16
|
+
*/
|
|
17
|
+
async function getFcmTokens(desktopId) {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
const snap = await db
|
|
20
|
+
.collection("desktops")
|
|
21
|
+
.doc(desktopId)
|
|
22
|
+
.collection("fcmTokens")
|
|
23
|
+
.get();
|
|
24
|
+
|
|
25
|
+
return snap.docs.map((doc) => ({
|
|
26
|
+
token: doc.data().token,
|
|
27
|
+
platform: doc.id,
|
|
28
|
+
docRef: doc.ref,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Core send function
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send a push notification to all devices registered for a desktop.
|
|
38
|
+
* Uses FCM HTTP v1 API via Firebase Admin SDK.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} desktopId - Desktop document ID
|
|
41
|
+
* @param {object} notification - { title, body }
|
|
42
|
+
* @param {object} data - Custom data payload (all values must be strings)
|
|
43
|
+
*/
|
|
44
|
+
async function sendPushNotification(desktopId, notification, data = {}) {
|
|
45
|
+
let tokens;
|
|
46
|
+
try {
|
|
47
|
+
tokens = await getFcmTokens(desktopId);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
log.warn(
|
|
50
|
+
`[notifications] Failed to read FCM tokens for ${desktopId}: ${e.message}`,
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tokens.length === 0) return;
|
|
56
|
+
|
|
57
|
+
const messaging = getMessaging();
|
|
58
|
+
|
|
59
|
+
for (const { token, platform, docRef } of tokens) {
|
|
60
|
+
try {
|
|
61
|
+
await messaging.send({
|
|
62
|
+
token,
|
|
63
|
+
notification,
|
|
64
|
+
data,
|
|
65
|
+
// Use high priority for permission requests on Android.
|
|
66
|
+
android: {
|
|
67
|
+
priority: data.type === "permission_request" ? "high" : "normal",
|
|
68
|
+
notification: {
|
|
69
|
+
channelId:
|
|
70
|
+
data.type === "permission_request"
|
|
71
|
+
? "permissions_channel"
|
|
72
|
+
: "sessions_channel",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
apns: {
|
|
76
|
+
payload: {
|
|
77
|
+
aps: {
|
|
78
|
+
alert: notification,
|
|
79
|
+
sound: "default",
|
|
80
|
+
// Use critical alert priority for permission requests.
|
|
81
|
+
...(data.type === "permission_request" && {
|
|
82
|
+
"interruption-level": "time-sensitive",
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
const code = e.code || "";
|
|
90
|
+
// Clean up invalid/expired tokens.
|
|
91
|
+
if (
|
|
92
|
+
code === "messaging/registration-token-not-registered" ||
|
|
93
|
+
code === "messaging/invalid-registration-token"
|
|
94
|
+
) {
|
|
95
|
+
log.warn(
|
|
96
|
+
`[notifications] Removing stale FCM token for ${platform}: ${code}`,
|
|
97
|
+
);
|
|
98
|
+
try {
|
|
99
|
+
await docRef.delete();
|
|
100
|
+
} catch {
|
|
101
|
+
// Best-effort cleanup.
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
log.warn(`[notifications] Failed to send to ${platform}: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Convenience methods — called from session-manager.js
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Notify mobile that a permission request needs approval.
|
|
116
|
+
*/
|
|
117
|
+
export async function notifyPermissionRequest(
|
|
118
|
+
desktopId,
|
|
119
|
+
sessionId,
|
|
120
|
+
{ toolName, commandPreview, projectName },
|
|
121
|
+
) {
|
|
122
|
+
await sendPushNotification(
|
|
123
|
+
desktopId,
|
|
124
|
+
{
|
|
125
|
+
title: "Permission Needed",
|
|
126
|
+
body: `${toolName}: ${commandPreview}`.slice(0, 200),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: "permission_request",
|
|
130
|
+
sessionId,
|
|
131
|
+
toolName: toolName || "",
|
|
132
|
+
commandPreview: (commandPreview || "").slice(0, 200),
|
|
133
|
+
projectName: projectName || "",
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Notify mobile that a session completed successfully.
|
|
140
|
+
*/
|
|
141
|
+
export async function notifySessionComplete(
|
|
142
|
+
desktopId,
|
|
143
|
+
sessionId,
|
|
144
|
+
{ projectName },
|
|
145
|
+
) {
|
|
146
|
+
await sendPushNotification(
|
|
147
|
+
desktopId,
|
|
148
|
+
{
|
|
149
|
+
title: "Task Complete",
|
|
150
|
+
body: `${projectName}: Claude finished the task`,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "session_complete",
|
|
154
|
+
sessionId,
|
|
155
|
+
projectName: projectName || "",
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Notify mobile that a session encountered an error.
|
|
162
|
+
*/
|
|
163
|
+
export async function notifySessionError(
|
|
164
|
+
desktopId,
|
|
165
|
+
sessionId,
|
|
166
|
+
{ projectName, errorMessage },
|
|
167
|
+
) {
|
|
168
|
+
await sendPushNotification(
|
|
169
|
+
desktopId,
|
|
170
|
+
{
|
|
171
|
+
title: "Session Error",
|
|
172
|
+
body: `${projectName}: ${errorMessage || "An error occurred"}`.slice(
|
|
173
|
+
0,
|
|
174
|
+
200,
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: "session_error",
|
|
179
|
+
sessionId,
|
|
180
|
+
projectName: projectName || "",
|
|
181
|
+
errorMessage: (errorMessage || "").slice(0, 200),
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Notify mobile that Claude is waiting for input.
|
|
188
|
+
*/
|
|
189
|
+
export async function notifySessionIdle(desktopId, sessionId, { projectName }) {
|
|
190
|
+
await sendPushNotification(
|
|
191
|
+
desktopId,
|
|
192
|
+
{
|
|
193
|
+
title: "Claude is Waiting",
|
|
194
|
+
body: `${projectName}: Claude needs your input`,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "session_idle",
|
|
198
|
+
sessionId,
|
|
199
|
+
projectName: projectName || "",
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
package/src/session-manager.js
CHANGED
|
@@ -15,6 +15,13 @@ import {
|
|
|
15
15
|
stopCapturing,
|
|
16
16
|
hasActiveSimulator,
|
|
17
17
|
} from "./screenshot-manager.js";
|
|
18
|
+
import { postToSlack } from "./webhook-server.js";
|
|
19
|
+
import {
|
|
20
|
+
notifyPermissionRequest,
|
|
21
|
+
notifySessionComplete,
|
|
22
|
+
notifySessionError,
|
|
23
|
+
notifySessionIdle,
|
|
24
|
+
} from "./notifications.js";
|
|
18
25
|
|
|
19
26
|
// ---------------------------------------------------------------------------
|
|
20
27
|
// Resolve the user's shell environment so spawned processes inherit PATH,
|
|
@@ -153,10 +160,15 @@ const activeSessions = new Map();
|
|
|
153
160
|
*/
|
|
154
161
|
const MAX_SESSIONS = parseInt(process.env.FORGE_MAX_SESSIONS, 10) || 3;
|
|
155
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Maximum concurrent sessions allowed via webhook triggers.
|
|
165
|
+
*/
|
|
166
|
+
export const MAX_WEBHOOK_SESSIONS = 5;
|
|
167
|
+
|
|
156
168
|
/**
|
|
157
169
|
* Returns the count of real sessions (excluding command-watcher sentinel keys).
|
|
158
170
|
*/
|
|
159
|
-
function getActiveSessionCount() {
|
|
171
|
+
export function getActiveSessionCount() {
|
|
160
172
|
let count = 0;
|
|
161
173
|
for (const key of activeSessions.keys()) {
|
|
162
174
|
if (!key.startsWith("cmd-watcher-")) count++;
|
|
@@ -458,7 +470,7 @@ export async function startNewSession(desktopId, payload) {
|
|
|
458
470
|
);
|
|
459
471
|
}
|
|
460
472
|
|
|
461
|
-
const { prompt, projectPath, model } = payload || {};
|
|
473
|
+
const { prompt, projectPath, model, webhookMeta } = payload || {};
|
|
462
474
|
const db = getDb();
|
|
463
475
|
const resolvedModel = model || "sonnet";
|
|
464
476
|
const resolvedPath = projectPath || process.cwd();
|
|
@@ -503,6 +515,7 @@ export async function startNewSession(desktopId, payload) {
|
|
|
503
515
|
process: null,
|
|
504
516
|
desktopId,
|
|
505
517
|
projectPath: resolvedPath,
|
|
518
|
+
projectName,
|
|
506
519
|
model: resolvedModel,
|
|
507
520
|
startTime: Date.now(),
|
|
508
521
|
messageCount: 0,
|
|
@@ -511,6 +524,8 @@ export async function startNewSession(desktopId, payload) {
|
|
|
511
524
|
lastToolCall: null, // Last tool_use block (for permission requests)
|
|
512
525
|
permissionNeeded: false, // True when Claude reports permission denial
|
|
513
526
|
permissionWatcher: null, // Firestore unsubscribe for permission doc
|
|
527
|
+
webhookMeta: webhookMeta || null, // Webhook metadata for reply callbacks
|
|
528
|
+
lastAssistantText: "", // Last assistant message text (for webhook replies)
|
|
514
529
|
});
|
|
515
530
|
|
|
516
531
|
// Desktop terminal banner.
|
|
@@ -705,6 +720,13 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
705
720
|
"Process timed out — no output received. Claude CLI may need re-authentication or the model may be unavailable.",
|
|
706
721
|
});
|
|
707
722
|
|
|
723
|
+
// Push notification for timeout error.
|
|
724
|
+
const timeoutSess = activeSessions.get(sessionId);
|
|
725
|
+
notifySessionError(timeoutSess?.desktopId || desktopId, sessionId, {
|
|
726
|
+
projectName: timeoutSess?.projectName || "Unknown",
|
|
727
|
+
errorMessage: "Process timed out — no output received",
|
|
728
|
+
}).catch(() => {});
|
|
729
|
+
|
|
708
730
|
await db
|
|
709
731
|
.collection("sessions")
|
|
710
732
|
.doc(sessionId)
|
|
@@ -862,6 +884,17 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
862
884
|
lastActivity: FieldValue.serverTimestamp(),
|
|
863
885
|
});
|
|
864
886
|
watchPermissionDecision(sessionId, permDocId);
|
|
887
|
+
|
|
888
|
+
// Push notification for permission request.
|
|
889
|
+
notifyPermissionRequest(sess.desktopId, sessionId, {
|
|
890
|
+
toolName: toolForPermission.name || "Unknown tool",
|
|
891
|
+
commandPreview:
|
|
892
|
+
toolForPermission.input?.command ||
|
|
893
|
+
toolForPermission.input?.content ||
|
|
894
|
+
JSON.stringify(toolForPermission.input || {}).slice(0, 200),
|
|
895
|
+
projectName: sess.projectName || "Unknown",
|
|
896
|
+
}).catch(() => {});
|
|
897
|
+
|
|
865
898
|
log.session(
|
|
866
899
|
sessionId,
|
|
867
900
|
"Permission needed — waiting for mobile approval",
|
|
@@ -884,6 +917,19 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
884
917
|
timestamp: FieldValue.serverTimestamp(),
|
|
885
918
|
});
|
|
886
919
|
|
|
920
|
+
// Push notification for idle.
|
|
921
|
+
notifySessionIdle(sess.desktopId, sessionId, {
|
|
922
|
+
projectName: sess.projectName || "Unknown",
|
|
923
|
+
}).catch(() => {});
|
|
924
|
+
|
|
925
|
+
// If this session was triggered by a webhook with a reply URL,
|
|
926
|
+
// post Claude's last response back to the source (e.g., Slack).
|
|
927
|
+
if (sess?.webhookMeta?.replyUrl && sess.lastAssistantText) {
|
|
928
|
+
postToSlack(sess.webhookMeta.replyUrl, sess.lastAssistantText).catch(
|
|
929
|
+
() => {},
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
887
933
|
log.session(
|
|
888
934
|
sessionId,
|
|
889
935
|
"Turn complete — session idle, waiting for input",
|
|
@@ -917,6 +963,12 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
917
963
|
timestamp: FieldValue.serverTimestamp(),
|
|
918
964
|
});
|
|
919
965
|
|
|
966
|
+
// Push notification for error.
|
|
967
|
+
notifySessionError(sess?.desktopId || desktopId, sessionId, {
|
|
968
|
+
projectName: sess?.projectName || "Unknown",
|
|
969
|
+
errorMessage: `Process exited with code ${code}`,
|
|
970
|
+
}).catch(() => {});
|
|
971
|
+
|
|
920
972
|
log.sessionEnded({
|
|
921
973
|
sessionId,
|
|
922
974
|
status: "error",
|
|
@@ -938,6 +990,12 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
938
990
|
errorMessage: `Failed to start: ${err.message}`,
|
|
939
991
|
});
|
|
940
992
|
|
|
993
|
+
// Push notification for spawn error.
|
|
994
|
+
notifySessionError(sess?.desktopId || desktopId, sessionId, {
|
|
995
|
+
projectName: sess?.projectName || "Unknown",
|
|
996
|
+
errorMessage: `Failed to start: ${err.message}`,
|
|
997
|
+
}).catch(() => {});
|
|
998
|
+
|
|
941
999
|
await db
|
|
942
1000
|
.collection("sessions")
|
|
943
1001
|
.doc(sessionId)
|
|
@@ -1005,6 +1063,13 @@ async function stopSession(sessionId) {
|
|
|
1005
1063
|
timestamp: FieldValue.serverTimestamp(),
|
|
1006
1064
|
});
|
|
1007
1065
|
|
|
1066
|
+
// Push notification for session completion.
|
|
1067
|
+
if (session?.desktopId) {
|
|
1068
|
+
notifySessionComplete(session.desktopId, sessionId, {
|
|
1069
|
+
projectName: session.projectName || "Unknown",
|
|
1070
|
+
}).catch(() => {});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1008
1073
|
log.sessionEnded({
|
|
1009
1074
|
sessionId,
|
|
1010
1075
|
status: "completed",
|
|
@@ -1263,7 +1328,10 @@ const PERMISSION_PATTERNS = [
|
|
|
1263
1328
|
async function storeAssistantMessage(sessionId, text) {
|
|
1264
1329
|
const db = getDb();
|
|
1265
1330
|
const session = activeSessions.get(sessionId);
|
|
1266
|
-
if (session)
|
|
1331
|
+
if (session) {
|
|
1332
|
+
session.messageCount = (session.messageCount || 0) + 1;
|
|
1333
|
+
session.lastAssistantText = text; // Track for webhook reply callbacks
|
|
1334
|
+
}
|
|
1267
1335
|
|
|
1268
1336
|
await db.collection("sessions").doc(sessionId).collection("messages").add({
|
|
1269
1337
|
type: "assistant",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks npm registry for newer versions and warns the user.
|
|
3
|
+
*
|
|
4
|
+
* Runs non-blocking — never delays startup if the check fails or times out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import * as log from "./logger.js";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
/** Read the local package version from package.json. */
|
|
15
|
+
function getLocalVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(
|
|
18
|
+
readFileSync(join(__dirname, "..", "package.json"), "utf-8"),
|
|
19
|
+
);
|
|
20
|
+
return pkg.version;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compare two semver strings. Returns true if remote is newer than local.
|
|
28
|
+
*/
|
|
29
|
+
function isNewer(local, remote) {
|
|
30
|
+
const parse = (v) => v.split(".").map(Number);
|
|
31
|
+
const [lMaj, lMin, lPat] = parse(local);
|
|
32
|
+
const [rMaj, rMin, rPat] = parse(remote);
|
|
33
|
+
if (rMaj !== lMaj) return rMaj > lMaj;
|
|
34
|
+
if (rMin !== lMin) return rMin > lMin;
|
|
35
|
+
return rPat > lPat;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check npm for a newer version. Logs a warning if one is found.
|
|
40
|
+
* Never throws — silently returns on any failure.
|
|
41
|
+
*/
|
|
42
|
+
export async function checkForUpdate() {
|
|
43
|
+
const localVersion = getLocalVersion();
|
|
44
|
+
if (!localVersion) return;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
49
|
+
|
|
50
|
+
const res = await fetch("https://registry.npmjs.org/forge-remote/latest", {
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
|
|
55
|
+
if (!res.ok) return;
|
|
56
|
+
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
const remoteVersion = data.version;
|
|
59
|
+
|
|
60
|
+
if (remoteVersion && isNewer(localVersion, remoteVersion)) {
|
|
61
|
+
console.log();
|
|
62
|
+
log.warn(
|
|
63
|
+
`A new version of forge-remote is available: ${localVersion} → ${remoteVersion}`,
|
|
64
|
+
);
|
|
65
|
+
log.warn(" Run: npm update -g forge-remote");
|
|
66
|
+
log.warn(" Then: forge-remote init (to update Firestore rules)");
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Network error, timeout, etc. — don't bother the user.
|
|
71
|
+
}
|
|
72
|
+
}
|