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 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.13",
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
- "repository": {
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://github.com/sponsors/IronForgeApps\n",
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
+ }
@@ -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) session.messageCount = (session.messageCount || 0) + 1;
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
+ }