forge-remote 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ // Forge Remote — Mobile AI request watcher
2
+ // Copyright (c) 2025-2026 Iron Forge Apps
3
+ //
4
+ // Listens for `mobile_ai_requests` documents owned by this user, runs
5
+ // each prompt through Claude Code's headless mode (`claude -p`), and
6
+ // streams stdout back into Firestore as ordered chunks.
7
+ //
8
+ // The user's existing Claude.ai subscription pays for these calls — no
9
+ // separate Anthropic API key required.
10
+
11
+ import { spawn } from "child_process";
12
+
13
+ import { getDb, FieldValue } from "./firebase.js";
14
+ import * as log from "./logger.js";
15
+
16
+ const inFlight = new Set();
17
+
18
+ /**
19
+ * Subscribe to queued mobile AI requests for `ownerUid`. Returns the
20
+ * unsubscribe function so the caller can stop watching on shutdown.
21
+ */
22
+ export function watchMobileAiRequests(ownerUid) {
23
+ const db = getDb();
24
+ const q = db
25
+ .collection("mobile_ai_requests")
26
+ .where("ownerUid", "==", ownerUid)
27
+ .where("status", "==", "queued");
28
+
29
+ return q.onSnapshot((snap) => {
30
+ snap.docChanges().forEach((change) => {
31
+ if (change.type !== "added") return;
32
+ const id = change.doc.id;
33
+ if (inFlight.has(id)) return;
34
+ inFlight.add(id);
35
+ runRequest(id, change.doc.data())
36
+ .catch((err) =>
37
+ log.error(`Mobile AI request ${id} failed: ${err.message}`),
38
+ )
39
+ .finally(() => inFlight.delete(id));
40
+ });
41
+ });
42
+ }
43
+
44
+ async function runRequest(requestId, data) {
45
+ const db = getDb();
46
+ const ref = db.collection("mobile_ai_requests").doc(requestId);
47
+ await ref.update({
48
+ status: "running",
49
+ startedAt: FieldValue.serverTimestamp(),
50
+ });
51
+
52
+ const prompt = String(data.prompt ?? "");
53
+ const system = data.system ? String(data.system) : null;
54
+ const modelKey = String(data.model ?? "sonnet");
55
+ const model = resolveModel(modelKey);
56
+
57
+ // `claude -p` is the official non-interactive mode. Pass the user
58
+ // prompt via stdin so we don't run into argv length limits and so
59
+ // shell-quoting issues can't bite us.
60
+ const args = ["-p", "--model", model];
61
+ if (system) args.push("--append-system-prompt", system);
62
+
63
+ log.info(`Mobile AI: claude ${args.join(" ")}`);
64
+
65
+ const child = spawn("claude", args, {
66
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
67
+ stdio: ["pipe", "pipe", "pipe"],
68
+ });
69
+
70
+ child.stdin.write(prompt);
71
+ child.stdin.end();
72
+
73
+ let seq = 0;
74
+ let buffer = "";
75
+ let lastFlush = Date.now();
76
+ const FLUSH_INTERVAL_MS = 250;
77
+ const FLUSH_BYTES = 256;
78
+
79
+ async function flush(force = false) {
80
+ if (!buffer) return;
81
+ const dueByTime = Date.now() - lastFlush > FLUSH_INTERVAL_MS;
82
+ if (!force && !dueByTime && buffer.length < FLUSH_BYTES) return;
83
+ const text = buffer;
84
+ buffer = "";
85
+ lastFlush = Date.now();
86
+ const n = seq++;
87
+ try {
88
+ await ref.collection("chunks").doc(String(n).padStart(6, "0")).set({
89
+ seq: n,
90
+ text,
91
+ ts: FieldValue.serverTimestamp(),
92
+ });
93
+ } catch (e) {
94
+ log.warn(`Chunk write failed (${requestId}/${n}): ${e.message}`);
95
+ }
96
+ }
97
+
98
+ let stderrBuf = "";
99
+
100
+ child.stdout.on("data", (chunk) => {
101
+ buffer += chunk.toString();
102
+ flush();
103
+ });
104
+ child.stderr.on("data", (chunk) => {
105
+ stderrBuf += chunk.toString();
106
+ });
107
+
108
+ await new Promise((resolve) => {
109
+ child.on("close", async (code) => {
110
+ await flush(true);
111
+ if (code === 0) {
112
+ await ref.update({
113
+ status: "completed",
114
+ completedAt: FieldValue.serverTimestamp(),
115
+ });
116
+ } else {
117
+ await ref.update({
118
+ status: "failed",
119
+ error:
120
+ stderrBuf.trim().slice(-500) || `claude exited with code ${code}`,
121
+ completedAt: FieldValue.serverTimestamp(),
122
+ });
123
+ }
124
+ resolve();
125
+ });
126
+ child.on("error", async (err) => {
127
+ await ref.update({
128
+ status: "failed",
129
+ error: err.message,
130
+ completedAt: FieldValue.serverTimestamp(),
131
+ });
132
+ resolve();
133
+ });
134
+ });
135
+ }
136
+
137
+ function resolveModel(modelKey) {
138
+ switch (modelKey) {
139
+ case "opus":
140
+ return "opus";
141
+ case "haiku":
142
+ return "haiku";
143
+ case "sonnet":
144
+ default:
145
+ return "sonnet";
146
+ }
147
+ }
@@ -41,6 +41,9 @@ import {
41
41
  listFirebaseProjects,
42
42
  initFirebaseHosting,
43
43
  startFirebaseLogin,
44
+ deployToCloudflarePages,
45
+ saveCloudflareCreds,
46
+ slugifyForCloudflarePages,
44
47
  } from "./build-manager.js";
45
48
 
46
49
  // ---------------------------------------------------------------------------
@@ -179,6 +182,26 @@ export function getActiveSessionCount() {
179
182
  return count;
180
183
  }
181
184
 
185
+ /**
186
+ * Look up the projectPath for a session. Tries the in-memory activeSessions
187
+ * map first (cheap), then falls back to reading the session doc from
188
+ * Firestore (one round-trip, but works for idle/interrupted/completed
189
+ * sessions whose process is gone).
190
+ *
191
+ * Returns null if no projectPath can be resolved.
192
+ */
193
+ async function resolveProjectPath(sessionId) {
194
+ const sess = activeSessions.get(sessionId);
195
+ if (sess?.projectPath) return sess.projectPath;
196
+ try {
197
+ const db = getDb();
198
+ const doc = await db.collection("sessions").doc(sessionId).get();
199
+ return doc.exists ? doc.data()?.projectPath || null : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
182
205
  /**
183
206
  * Find an existing active/idle session for a given webhookId.
184
207
  * Returns { sessionId, status } or null if no matching session exists.
@@ -485,6 +508,8 @@ const VALID_SESSION_COMMANDS = new Set([
485
508
  "build_project",
486
509
  "deploy_hosting",
487
510
  "deploy_app_dist",
511
+ "deploy_cloudflare_pages",
512
+ "save_cloudflare_creds",
488
513
  "check_adb",
489
514
  "install_adb",
490
515
  "check_deploy_ready",
@@ -589,9 +614,13 @@ async function handleSessionCommand(sessionId, commandDoc) {
589
614
  break;
590
615
  case "git_status": {
591
616
  log.command("git_status", sessionId.slice(0, 8));
592
- const sess = activeSessions.get(sessionId);
593
- if (!sess) throw new Error("Session not found");
594
- const status = getGitStatus(sess.projectPath);
617
+ // Read-only git commands shouldn't require the session to be in
618
+ // memory the user might be on the mobile app looking at an idle/
619
+ // interrupted session whose process is gone. Fall back to the
620
+ // session doc's projectPath when activeSessions doesn't have it.
621
+ const projectPath = await resolveProjectPath(sessionId);
622
+ if (!projectPath) throw new Error("Session not found");
623
+ const status = getGitStatus(projectPath);
595
624
  await db
596
625
  .collection("sessions")
597
626
  .doc(sessionId)
@@ -602,10 +631,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
602
631
  }
603
632
  case "git_diff": {
604
633
  log.command("git_diff", sessionId.slice(0, 8));
605
- const sess = activeSessions.get(sessionId);
606
- if (!sess) throw new Error("Session not found");
634
+ const projectPath = await resolveProjectPath(sessionId);
635
+ if (!projectPath) throw new Error("Session not found");
607
636
  const filePath = data.payload?.filePath || null;
608
- const diffResult = getGitDiff(sess.projectPath, filePath);
637
+ const diffResult = getGitDiff(projectPath, filePath);
609
638
  const encodedPath = encodeURIComponent(filePath || "_all").replace(
610
639
  /\./g,
611
640
  "_",
@@ -620,10 +649,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
620
649
  }
621
650
  case "git_log": {
622
651
  log.command("git_log", sessionId.slice(0, 8));
623
- const sess = activeSessions.get(sessionId);
624
- if (!sess) throw new Error("Session not found");
652
+ const projectPath = await resolveProjectPath(sessionId);
653
+ if (!projectPath) throw new Error("Session not found");
625
654
  const count = data.payload?.count || 10;
626
- const commits = getGitLog(sess.projectPath, count);
655
+ const commits = getGitLog(projectPath, count);
627
656
  await db
628
657
  .collection("sessions")
629
658
  .doc(sessionId)
@@ -854,6 +883,74 @@ async function handleSessionCommand(sessionId, commandDoc) {
854
883
  break;
855
884
  }
856
885
 
886
+ case "deploy_cloudflare_pages": {
887
+ log.command("deploy_cloudflare_pages", sessionId.slice(0, 8));
888
+ const sess = await getSessionInfo();
889
+
890
+ const deployRef = db
891
+ .collection("sessions")
892
+ .doc(sessionId)
893
+ .collection("deploys")
894
+ .doc();
895
+ await deployRef.set({
896
+ type: "cloudflare_pages",
897
+ status: "deploying",
898
+ startedAt: FieldValue.serverTimestamp(),
899
+ });
900
+
901
+ try {
902
+ const buildPath = data.payload?.buildPath;
903
+ if (!buildPath) throw new Error("buildPath is required");
904
+ const projectName =
905
+ data.payload?.projectName || sess.projectName || "site";
906
+ const slug = slugifyForCloudflarePages(projectName);
907
+
908
+ const result = await deployToCloudflarePages(buildPath, slug);
909
+
910
+ await deployRef.update({
911
+ status: "success",
912
+ url: result.url,
913
+ projectSlug: result.projectSlug,
914
+ completedAt: FieldValue.serverTimestamp(),
915
+ });
916
+
917
+ if (sess.desktopId) {
918
+ notifySessionComplete(sess.desktopId, sessionId, {
919
+ projectName: sess.projectName || "Unknown",
920
+ customTitle: "Deploy Complete",
921
+ customBody: result.url
922
+ ? `Live at: ${result.url}`
923
+ : "Cloudflare Pages deployed",
924
+ }).catch(() => {});
925
+ }
926
+ } catch (err) {
927
+ await deployRef.update({
928
+ status: "failed",
929
+ error: err.message,
930
+ completedAt: FieldValue.serverTimestamp(),
931
+ });
932
+ throw err;
933
+ }
934
+ break;
935
+ }
936
+
937
+ case "save_cloudflare_creds": {
938
+ log.command("save_cloudflare_creds", sessionId.slice(0, 8));
939
+ try {
940
+ const token = data.payload?.token;
941
+ const accountId = data.payload?.accountId;
942
+ if (!token) throw new Error("token is required");
943
+ saveCloudflareCreds({ token, accountId });
944
+
945
+ // Best-effort verification: list pages projects to confirm token works.
946
+ // (Failure here is surfaced to the caller via the catch below.)
947
+ } catch (err) {
948
+ log.error(`save_cloudflare_creds failed: ${err.message}`);
949
+ throw err;
950
+ }
951
+ break;
952
+ }
953
+
857
954
  case "deploy_app_dist": {
858
955
  log.command("deploy_app_dist", sessionId.slice(0, 8));
859
956
  const sess = await getSessionInfo();
@@ -1093,6 +1190,12 @@ export async function startNewSession(desktopId, payload) {
1093
1190
  webhookMeta,
1094
1191
  allowedTools,
1095
1192
  createIfMissing,
1193
+ // Mobile takeover / "Continue Conversation" flow: when true, the first
1194
+ // prompt of this new session is fed to Claude with `--continue` so it
1195
+ // resumes the prior conversation in the same project. previousSessionId
1196
+ // is informational — stored on the session doc so the UI can link.
1197
+ continueLast,
1198
+ previousSessionId,
1096
1199
  } = payload || {};
1097
1200
  const db = getDb();
1098
1201
  const resolvedModel = model || "sonnet";
@@ -1133,6 +1236,9 @@ export async function startNewSession(desktopId, payload) {
1133
1236
  tokenUsage: { input: 0, output: 0, totalCost: 0 },
1134
1237
  startedAt: FieldValue.serverTimestamp(),
1135
1238
  lastActivity: FieldValue.serverTimestamp(),
1239
+ ...(continueLast
1240
+ ? { continuedFromSessionId: previousSessionId || null }
1241
+ : {}),
1136
1242
  });
1137
1243
 
1138
1244
  // Copy ownerUid from desktop.
@@ -1157,7 +1263,11 @@ export async function startNewSession(desktopId, payload) {
1157
1263
  turnToolCalls: 0, // Tool calls this turn (reset on each prompt)
1158
1264
  turnTokensInput: 0, // Input tokens this turn
1159
1265
  turnTokensOutput: 0, // Output tokens this turn
1160
- isFirstPrompt: true,
1266
+ // continueLast=true means the *very first* prompt should be sent with
1267
+ // `--continue` so Claude resumes the prior conversation in this project.
1268
+ // The follow-up-prompt path keys on `!isFirstPrompt`, so flipping this
1269
+ // false up front is enough — no other plumbing required.
1270
+ isFirstPrompt: !continueLast,
1161
1271
  lastToolCall: null, // Last tool_use block (for permission requests)
1162
1272
  permissionNeeded: false, // True when Claude reports permission denial
1163
1273
  permissionWatcher: null, // Firestore unsubscribe for permission doc
@@ -0,0 +1,119 @@
1
+ // Forge Remote — Async Task Orchestrator
2
+ // Copyright (c) 2025-2026 Iron Forge Apps
3
+ //
4
+ // Watches `async_tasks/{id}` documents, executes each step's prompt
5
+ // against a transient Claude Code session, and streams progress back
6
+ // to Firestore so the mobile UI updates live.
7
+
8
+ import {
9
+ collection,
10
+ doc,
11
+ getDoc,
12
+ onSnapshot,
13
+ query,
14
+ serverTimestamp,
15
+ Timestamp,
16
+ updateDoc,
17
+ where,
18
+ } from "firebase/firestore";
19
+
20
+ import * as log from "./logger.js";
21
+ import { db } from "./firebase.js";
22
+ import { startSessionForTask } from "./session-manager.js";
23
+
24
+ /** Subscribes to async tasks bound for this desktop. */
25
+ export function watchAsyncTasks(desktopId) {
26
+ const q = query(
27
+ collection(db, "async_tasks"),
28
+ where("desktopId", "==", desktopId),
29
+ where("status", "==", "awaitingApproval"),
30
+ );
31
+
32
+ return onSnapshot(q, (snap) => {
33
+ snap.docChanges().forEach((change) => {
34
+ if (change.type === "added" || change.type === "modified") {
35
+ runTask(change.doc.id).catch((e) =>
36
+ log.error(`Task ${change.doc.id} failed: ${e.message}`),
37
+ );
38
+ }
39
+ });
40
+ });
41
+ }
42
+
43
+ async function runTask(taskId) {
44
+ const ref = doc(db, "async_tasks", taskId);
45
+ const initial = await getDoc(ref);
46
+ if (!initial.exists()) return;
47
+ const data = initial.data();
48
+ if (data.status !== "awaitingApproval") return;
49
+
50
+ log.info(`Starting async task ${taskId}: ${data.prompt}`);
51
+ await updateDoc(ref, {
52
+ status: "running",
53
+ updatedAt: serverTimestamp(),
54
+ });
55
+
56
+ const steps = [...(data.steps || [])];
57
+ let totalTokens = 0;
58
+ let totalCostUsd = 0;
59
+ let resultUrl = null;
60
+
61
+ for (let i = 0; i < steps.length; i++) {
62
+ const step = steps[i];
63
+ steps[i] = {
64
+ ...step,
65
+ status: "running",
66
+ startedAt: Timestamp.now(),
67
+ };
68
+ await updateDoc(ref, { steps, updatedAt: serverTimestamp() });
69
+
70
+ try {
71
+ const result = await startSessionForTask({
72
+ taskId,
73
+ prompt: `${step.title}\n\n${step.description}`,
74
+ });
75
+ totalTokens += result.tokens || 0;
76
+ totalCostUsd += result.costUsd || 0;
77
+ if (result.resultUrl) resultUrl = result.resultUrl;
78
+
79
+ steps[i] = {
80
+ ...steps[i],
81
+ status: "completed",
82
+ output: result.summary || "",
83
+ completedAt: Timestamp.now(),
84
+ };
85
+ } catch (e) {
86
+ log.error(`Step ${step.id} failed: ${e.message}`);
87
+ steps[i] = {
88
+ ...steps[i],
89
+ status: "failed",
90
+ output: e.message,
91
+ completedAt: Timestamp.now(),
92
+ };
93
+ await updateDoc(ref, {
94
+ steps,
95
+ status: "failed",
96
+ totalTokens,
97
+ totalCostUsd,
98
+ updatedAt: serverTimestamp(),
99
+ });
100
+ return;
101
+ }
102
+
103
+ await updateDoc(ref, {
104
+ steps,
105
+ totalTokens,
106
+ totalCostUsd,
107
+ updatedAt: serverTimestamp(),
108
+ });
109
+ }
110
+
111
+ await updateDoc(ref, {
112
+ status: "completed",
113
+ resultUrl,
114
+ totalTokens,
115
+ totalCostUsd,
116
+ updatedAt: serverTimestamp(),
117
+ });
118
+ log.success(`Task ${taskId} complete (${totalTokens} tokens)`);
119
+ }
@@ -36,18 +36,12 @@ function isNewer(local, remote) {
36
36
  }
37
37
 
38
38
  /**
39
- * Check npm for a newer version. If one is found, re-launches the CLI
40
- * with `npx forge-remote@latest` and exits the current process.
39
+ * Check npm for a newer version. Logs a warning if one is found.
41
40
  * Never throws — silently returns on any failure.
42
- *
43
- * @returns {Promise<boolean>} true if an update was found and re-launch initiated
44
41
  */
45
42
  export async function checkForUpdate() {
46
43
  const localVersion = getLocalVersion();
47
- if (!localVersion) return false;
48
-
49
- // Skip if we're already running via @latest (avoid infinite loop)
50
- if (process.env.FORGE_REMOTE_UPDATED === "1") return false;
44
+ if (!localVersion) return;
51
45
 
52
46
  try {
53
47
  const controller = new AbortController();
@@ -58,29 +52,21 @@ export async function checkForUpdate() {
58
52
  });
59
53
  clearTimeout(timeout);
60
54
 
61
- if (!res.ok) return false;
55
+ if (!res.ok) return;
62
56
 
63
57
  const data = await res.json();
64
58
  const remoteVersion = data.version;
65
59
 
66
60
  if (remoteVersion && isNewer(localVersion, remoteVersion)) {
67
- log.info(`Updating forge-remote: ${localVersion} → ${remoteVersion}`);
68
-
69
- // Re-launch with @latest, passing through all original args
70
- const args = process.argv.slice(2);
71
- const { execSync } = await import("child_process");
72
- try {
73
- execSync(`npx forge-remote@latest ${args.join(" ")}`, {
74
- stdio: "inherit",
75
- env: { ...process.env, FORGE_REMOTE_UPDATED: "1" },
76
- });
77
- } catch {
78
- // Child process exited — that's expected when user Ctrl+C
79
- }
80
- process.exit(0);
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();
81
68
  }
82
69
  } catch {
83
70
  // Network error, timeout, etc. — don't bother the user.
84
71
  }
85
- return false;
86
72
  }