forge-remote 0.1.10 → 0.1.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
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",
package/src/init.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Copyright (c) 2025-2026 Iron Forge Apps
3
3
  // Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
4
4
 
5
- import { execSync, execFileSync } from "child_process";
5
+ import { execSync, execFileSync, spawn } from "child_process";
6
6
  import { createInterface } from "readline";
7
7
  import { hostname, platform, homedir } from "os";
8
8
  import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "fs";
@@ -119,14 +119,24 @@ function waitForEnter(message) {
119
119
 
120
120
  /**
121
121
  * Open a URL in the default browser (best-effort, no throw).
122
+ * Uses spawn + detach to avoid blocking or suspending the terminal.
122
123
  */
123
124
  function openBrowser(url) {
124
125
  const p = platform();
125
126
  try {
126
- if (p === "darwin") execFileSync("open", [url], { stdio: "pipe" });
127
- else if (p === "win32")
128
- execFileSync("cmd", ["/c", "start", "", url], { stdio: "pipe" });
129
- else execFileSync("xdg-open", [url], { stdio: "pipe" });
127
+ let cmd, args;
128
+ if (p === "darwin") {
129
+ cmd = "open";
130
+ args = [url];
131
+ } else if (p === "win32") {
132
+ cmd = "cmd";
133
+ args = ["/c", "start", "", url];
134
+ } else {
135
+ cmd = "xdg-open";
136
+ args = [url];
137
+ }
138
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
139
+ child.unref();
130
140
  } catch {
131
141
  // Silently fail — URL is always printed for manual opening.
132
142
  }
@@ -597,6 +597,7 @@ async function sendFollowUpPrompt(sessionId, prompt) {
597
597
  session.permissionWatcher = null;
598
598
  }
599
599
  session.permissionNeeded = false;
600
+ session.deniedToolCall = null;
600
601
 
601
602
  // Store the user's message in Firestore.
602
603
  const db = getDb();
@@ -625,6 +626,7 @@ async function runClaudeProcess(sessionId, prompt) {
625
626
 
626
627
  // Reset permission state for this new turn.
627
628
  session.permissionNeeded = false;
629
+ session.deniedToolCall = null;
628
630
  session.lastToolCall = null;
629
631
 
630
632
  const db = getDb();
@@ -719,7 +721,11 @@ async function runClaudeProcess(sessionId, prompt) {
719
721
  // ── Parse stdout (stream-json) ──
720
722
  let stdoutBuffer = "";
721
723
 
722
- claudeProcess.stdout.on("data", async (data) => {
724
+ // Track pending async data processing so the close handler can wait for it.
725
+ // Without this, the close event can fire before permission detection completes.
726
+ let pendingDataProcessing = Promise.resolve();
727
+
728
+ claudeProcess.stdout.on("data", (data) => {
723
729
  if (!receivedOutput) {
724
730
  receivedOutput = true;
725
731
  clearTimeout(watchdog);
@@ -727,40 +733,50 @@ async function runClaudeProcess(sessionId, prompt) {
727
733
  log.session(sessionId, "First output received from Claude");
728
734
  }
729
735
 
730
- stdoutBuffer += data.toString();
731
- const lines = stdoutBuffer.split("\n");
732
- stdoutBuffer = lines.pop();
733
-
734
- for (const line of lines) {
735
- if (!line.trim()) continue;
736
- try {
737
- const event = JSON.parse(line);
738
- await handleStreamEvent(sessionId, sessionRef, event);
739
- } catch {
740
- if (line.trim()) {
741
- const text = line.trim();
742
- log.claudeOutput(sessionId, text);
743
-
744
- // Detect auth / login errors that come as plain text.
745
- if (/not logged in|please run.*login/i.test(text)) {
746
- log.error(
747
- `[${sessionId.slice(0, 8)}] Claude CLI auth error: ${text}`,
748
- );
749
- await db
750
- .collection("sessions")
751
- .doc(sessionId)
752
- .collection("messages")
753
- .add({
754
- type: "system",
755
- content: `Auth error: ${text}. Run "claude /login" in your terminal, then restart the relay.`,
756
- timestamp: FieldValue.serverTimestamp(),
757
- });
758
- } else {
759
- await storeAssistantMessage(sessionId, text);
736
+ // Chain onto the pending processing promise to ensure sequential handling
737
+ // AND to let the close handler wait for all processing to finish.
738
+ pendingDataProcessing = pendingDataProcessing
739
+ .then(async () => {
740
+ stdoutBuffer += data.toString();
741
+ const lines = stdoutBuffer.split("\n");
742
+ stdoutBuffer = lines.pop();
743
+
744
+ for (const line of lines) {
745
+ if (!line.trim()) continue;
746
+ try {
747
+ const event = JSON.parse(line);
748
+ await handleStreamEvent(sessionId, sessionRef, event);
749
+ } catch {
750
+ if (line.trim()) {
751
+ const text = line.trim();
752
+ log.claudeOutput(sessionId, text);
753
+
754
+ // Detect auth / login errors that come as plain text.
755
+ if (/not logged in|please run.*login/i.test(text)) {
756
+ log.error(
757
+ `[${sessionId.slice(0, 8)}] Claude CLI auth error: ${text}`,
758
+ );
759
+ await db
760
+ .collection("sessions")
761
+ .doc(sessionId)
762
+ .collection("messages")
763
+ .add({
764
+ type: "system",
765
+ content: `Auth error: ${text}. Run "claude /login" in your terminal, then restart the relay.`,
766
+ timestamp: FieldValue.serverTimestamp(),
767
+ });
768
+ } else {
769
+ await storeAssistantMessage(sessionId, text);
770
+ }
771
+ }
760
772
  }
761
773
  }
762
- }
763
- }
774
+ })
775
+ .catch((err) => {
776
+ log.error(
777
+ `[${sessionId.slice(0, 8)}] Error processing stdout: ${err.message}`,
778
+ );
779
+ });
764
780
  });
765
781
 
766
782
  // ── Stderr ──
@@ -800,6 +816,12 @@ async function runClaudeProcess(sessionId, prompt) {
800
816
  claudeProcess.on("close", async (code, signal) => {
801
817
  clearTimeout(watchdog);
802
818
  clearTimeout(killTimer);
819
+
820
+ // Wait for all pending stdout data processing to complete before checking
821
+ // permission state. Without this, the close event can race ahead of the
822
+ // async data handler, causing permissionNeeded to still be false.
823
+ await pendingDataProcessing;
824
+
803
825
  log.session(
804
826
  sessionId,
805
827
  `Process exited — code: ${code}, signal: ${signal}, PID: ${claudeProcess.pid}`,
@@ -826,11 +848,14 @@ async function runClaudeProcess(sessionId, prompt) {
826
848
  if (sess) sess.process = null; // Clear process so follow-ups can spawn.
827
849
 
828
850
  if (code === 0) {
829
- if (sess?.permissionNeeded && sess?.lastToolCall) {
851
+ // Use deniedToolCall (frozen at detection time) so subsequent tool calls
852
+ // don't overwrite the permission target. Fall back to lastToolCall.
853
+ const toolForPermission = sess?.deniedToolCall || sess?.lastToolCall;
854
+ if (sess?.permissionNeeded && toolForPermission) {
830
855
  // Claude was denied a tool call — create a permission request and wait.
831
856
  const permDocId = await createPermissionRequest(
832
857
  sessionId,
833
- sess.lastToolCall,
858
+ toolForPermission,
834
859
  );
835
860
  await sessionRef.update({
836
861
  status: "waiting_permission",
@@ -1218,15 +1243,21 @@ async function handleStreamEvent(sessionId, sessionRef, event) {
1218
1243
 
1219
1244
  // Patterns that indicate Claude was denied permission for a tool call.
1220
1245
  const PERMISSION_PATTERNS = [
1221
- /need your approval/i,
1222
- /permission (?:required|denied|blocked)/i,
1223
- /could you approve/i,
1246
+ /need(?:s|ing)? your approval/i,
1247
+ /awaiting your approval/i,
1248
+ /permission (?:required|denied|blocked|pending)/i,
1249
+ /could you.{0,10}approve/i,
1250
+ /please.{0,10}approve/i,
1224
1251
  /keeps? getting blocked/i,
1225
1252
  /permission system/i,
1226
1253
  /being blocked by/i,
1227
- /approve the command/i,
1254
+ /approve the (?:command|tool|action|edit)/i,
1228
1255
  /blocked by.+permission/i,
1229
1256
  /can'?t (?:execute|run).+(?:permission|approval)/i,
1257
+ /stuck on permission/i,
1258
+ /permission.{0,20}(?:is |still )?pending/i,
1259
+ /waiting for.{0,15}(?:permission|approval)/i,
1260
+ /need(?:s|ed)? (?:to be )?approv/i,
1230
1261
  ];
1231
1262
 
1232
1263
  async function storeAssistantMessage(sessionId, text) {
@@ -1241,10 +1272,13 @@ async function storeAssistantMessage(sessionId, text) {
1241
1272
  });
1242
1273
 
1243
1274
  // Check if Claude's message indicates a permission denial.
1275
+ // Save the denied tool call separately so subsequent tool calls don't
1276
+ // overwrite it — the permission request must be for the denied tool.
1244
1277
  if (session?.lastToolCall && !session.permissionNeeded) {
1245
1278
  for (const pattern of PERMISSION_PATTERNS) {
1246
1279
  if (pattern.test(text)) {
1247
1280
  session.permissionNeeded = true;
1281
+ session.deniedToolCall = { ...session.lastToolCall };
1248
1282
  log.info(
1249
1283
  `[${sessionId.slice(0, 8)}] Permission denial detected for ${session.lastToolCall.name}`,
1250
1284
  );
@@ -1817,8 +1851,9 @@ async function handlePermissionApproved(sessionId, permData) {
1817
1851
  const session = activeSessions.get(sessionId);
1818
1852
  if (!session) return;
1819
1853
 
1820
- const toolCall = session.lastToolCall;
1854
+ const toolCall = session.deniedToolCall || session.lastToolCall;
1821
1855
  session.permissionNeeded = false;
1856
+ session.deniedToolCall = null;
1822
1857
 
1823
1858
  log.success(
1824
1859
  `[${sessionId.slice(0, 8)}] Permission approved for ${permData.toolName}`,
@@ -1863,6 +1898,7 @@ async function handlePermissionDenied(sessionId) {
1863
1898
  if (!session) return;
1864
1899
 
1865
1900
  session.permissionNeeded = false;
1901
+ session.deniedToolCall = null;
1866
1902
  session.lastToolCall = null;
1867
1903
 
1868
1904
  log.info(`[${sessionId.slice(0, 8)}] Permission denied`);