forge-memory 0.2.117 → 0.3.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/README.md CHANGED
@@ -37,32 +37,35 @@ the wiki tools (`forge_search_wiki`, `forge_get_wiki_page`, and maintenance
37
37
  tools). It also exposes `forge_memory_mcp_diagnostics` so adapter startup issues
38
38
  show up as a tool result instead of a closed MCP transport.
39
39
 
40
- `pair-ios` prefers the Iroh QR. Forge starts a Rust Iroh host, prints a QR payload
41
- with the desktop node id, pairing token, optional relay hint, ALPN
42
- `forge-companion/1`, and the request URL as a direct fallback when it is
43
- phone-reachable. The iPhone app connects through its native Rust bridge first and can
44
- retry through URLSession when that bridge times out. The CLI renders a short-schema QR
45
- to keep the terminal code scannable and saves the full manual payload under
46
- `~/.forge/pairing/` so you can paste it into the iPhone app if the camera cannot scan.
47
- Use `--manual-http` only when you intentionally want a LAN, Tailscale, or direct
48
- HTTP/TCP route. For a real iPhone, pass a phone-reachable URL:
40
+ `pair-ios` prefers Tailscale when it is installed, running, authenticated, and Forge
41
+ is reachable through the Mac's MagicDNS HTTPS URL. That gives the iPhone a normal
42
+ phone-reachable web URL for sync and the embedded Forge WebView. If Tailscale is
43
+ running but Forge is not served yet, the installer asks before configuring
44
+ `tailscale serve` for the local Forge runtime. If Tailscale is unavailable or
45
+ declined, Forge falls back to the Iroh QR: a Rust Iroh host with the desktop node id,
46
+ pairing token, optional relay hint, and ALPN `forge-companion/1`.
47
+
48
+ The CLI renders a short-schema QR to keep the terminal code scannable and saves the
49
+ full manual payload under `~/.forge/pairing/` so you can paste it into the iPhone app
50
+ if the camera cannot scan. Use `--public-url` when you intentionally want to force a
51
+ specific Tailscale, LAN, or fixed/private URL:
49
52
 
50
53
  ```bash
51
- npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/
54
+ npx forge-memory pair-ios --public-url https://your-mac.tailnet.ts.net/forge/
52
55
  ```
53
56
 
54
- Without `--public-url`, manual HTTP may resolve to `127.0.0.1`, which is useful for
55
- the iOS Simulator but not for a physical phone.
57
+ `--manual-http` is still available as an explicit direct HTTP/TCP override. Loopback
58
+ URLs such as `127.0.0.1` and `localhost` are rejected for physical-phone pairing.
56
59
 
57
60
  The base install stays one command on purpose. The detailed companion transport
58
61
  reference lives in the Forge repo at `docs/companion-iroh.md` and in the published
59
62
  docs at `https://albertbuchard.github.io/forge/companion-transport.html`. Forge
60
63
  Memory ships Forge's Iroh host source and lockfile, not native desktop binaries. When
61
- the default iOS pairing flow is selected, the installer checks for Cargo, offers to
62
- install the minimal Rust toolchain when the platform supports it, builds the local
63
- host from the bundled source, then creates the QR. If Cargo cannot be installed
64
- automatically, `install`, `configure`, and `pair-ios` stop with platform-specific
65
- steps instead of printing a localhost QR that a physical iPhone cannot use.
64
+ the Iroh fallback is selected, the installer checks for Cargo, offers to install the
65
+ minimal Rust toolchain when the platform supports it, builds the local host from the
66
+ bundled source, then creates the QR. If Cargo cannot be installed automatically,
67
+ `install`, `configure`, and `pair-ios` stop with platform-specific steps instead of
68
+ printing a localhost QR that a physical iPhone cannot use.
66
69
 
67
70
  `configure` reruns the full guided flow using the current config as defaults.
68
71
  Install and configure run Forge doctor before finishing. `doctor --repair` creates
@@ -362,6 +362,219 @@ function rustInstallGuidance() {
362
362
  ];
363
363
  }
364
364
 
365
+ function tailscaleInstallPlan() {
366
+ if (process.platform === "darwin") {
367
+ return {
368
+ installable: true,
369
+ autoInstallCommand: commandExists("brew")
370
+ ? { command: "brew", args: ["install", "--cask", "tailscale"] }
371
+ : null,
372
+ guidance: [
373
+ "Install Tailscale for macOS from https://tailscale.com/download/mac or with Homebrew: brew install --cask tailscale",
374
+ "Open Tailscale, sign in, and make sure this Mac and the iPhone are in the same tailnet.",
375
+ "Then rerun: npx forge-memory pair-ios"
376
+ ]
377
+ };
378
+ }
379
+ if (process.platform === "linux") {
380
+ return {
381
+ installable: true,
382
+ autoInstallCommand: commandExists("curl")
383
+ ? {
384
+ command: "sh",
385
+ args: ["-c", "curl -fsSL https://tailscale.com/install.sh | sh"]
386
+ }
387
+ : null,
388
+ guidance: [
389
+ "Install Tailscale with your package manager or the official installer: curl -fsSL https://tailscale.com/install.sh | sh",
390
+ "Start and authenticate it: sudo tailscale up",
391
+ "Then rerun: npx forge-memory pair-ios"
392
+ ]
393
+ };
394
+ }
395
+ if (process.platform === "win32") {
396
+ return {
397
+ installable: true,
398
+ autoInstallCommand: commandExists("winget")
399
+ ? {
400
+ command: "winget",
401
+ args: [
402
+ "install",
403
+ "--id",
404
+ "Tailscale.Tailscale",
405
+ "-e",
406
+ "--source",
407
+ "winget",
408
+ "--accept-package-agreements",
409
+ "--accept-source-agreements"
410
+ ]
411
+ }
412
+ : null,
413
+ guidance: [
414
+ "Install Tailscale for Windows from https://tailscale.com/download/windows or with winget: winget install Tailscale.Tailscale",
415
+ "Sign in, make sure this PC and the iPhone are in the same tailnet, then rerun: npx forge-memory pair-ios"
416
+ ]
417
+ };
418
+ }
419
+ return {
420
+ installable: false,
421
+ autoInstallCommand: null,
422
+ guidance: [
423
+ "Install Tailscale from https://tailscale.com/download if your platform supports it, then rerun: npx forge-memory pair-ios"
424
+ ]
425
+ };
426
+ }
427
+
428
+ function tailscaleAutodetectDisabled() {
429
+ return ["1", "true", "yes"].includes(
430
+ String(process.env.FORGE_MEMORY_SKIP_TAILSCALE_AUTODETECT ?? "").toLowerCase()
431
+ );
432
+ }
433
+
434
+ function parseTailscaleStatus(raw) {
435
+ if (!raw) return { running: false, authenticated: false, dnsName: null };
436
+ try {
437
+ const payload = JSON.parse(raw);
438
+ const self = payload.Self ?? payload.self ?? null;
439
+ const dnsName = String(self?.DNSName ?? self?.dnsName ?? "")
440
+ .trim()
441
+ .replace(/\.$/, "");
442
+ const backendState = String(payload.BackendState ?? payload.backendState ?? "");
443
+ const running = backendState.toLowerCase() === "running";
444
+ return {
445
+ running,
446
+ authenticated: running && Boolean(dnsName),
447
+ dnsName: dnsName || null,
448
+ backendState: backendState || null
449
+ };
450
+ } catch {
451
+ return { running: false, authenticated: false, dnsName: null };
452
+ }
453
+ }
454
+
455
+ function normalizeForgePublicUiUrl(value) {
456
+ const normalized = normalizePublicPairingUrl(value);
457
+ if (!normalized) return null;
458
+ const url = new URL(normalized);
459
+ if (!url.pathname || url.pathname === "/" || url.pathname.startsWith("/api/")) {
460
+ url.pathname = "/forge/";
461
+ }
462
+ if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
463
+ url.search = "";
464
+ url.hash = "";
465
+ return url.toString();
466
+ }
467
+
468
+ function forgePublicHealthUrl(publicUiUrl) {
469
+ const url = new URL(publicUiUrl);
470
+ url.pathname = "/api/v1/health";
471
+ url.search = "";
472
+ url.hash = "";
473
+ return url;
474
+ }
475
+
476
+ function publicUrlFallbackMode(publicUrl) {
477
+ try {
478
+ const host = new URL(publicUrl).hostname.toLowerCase();
479
+ return host.endsWith(".ts.net") ? "tailscale" : "fixed-ip";
480
+ } catch {
481
+ return "fixed-ip";
482
+ }
483
+ }
484
+
485
+ function detectTailscaleState() {
486
+ if (tailscaleAutodetectDisabled()) {
487
+ return {
488
+ installed: false,
489
+ running: false,
490
+ authenticated: false,
491
+ publicUrl: null,
492
+ disabled: true
493
+ };
494
+ }
495
+ if (!commandExists("tailscale")) {
496
+ return {
497
+ installed: false,
498
+ running: false,
499
+ authenticated: false,
500
+ publicUrl: null,
501
+ installPlan: tailscaleInstallPlan()
502
+ };
503
+ }
504
+ const status = parseTailscaleStatus(runCapture("tailscale", ["status", "--json"], 4_000));
505
+ const envPublicUrl = normalizeForgePublicUiUrl(process.env.FORGE_MEMORY_TAILSCALE_PUBLIC_URL);
506
+ const publicUrl =
507
+ envPublicUrl ??
508
+ (status.dnsName ? `https://${status.dnsName}/forge/` : null);
509
+ return {
510
+ installed: true,
511
+ running: status.running,
512
+ authenticated: status.authenticated,
513
+ publicUrl,
514
+ backendState: status.backendState,
515
+ dnsName: status.dnsName
516
+ };
517
+ }
518
+
519
+ async function probePublicForgeUrl(publicUrl, timeoutMs = 4_000) {
520
+ if (!publicUrl) return { ok: false, error: "no public URL candidate" };
521
+ const fakeProbeSequencePath =
522
+ process.env.FORGE_MEMORY_FAKE_TAILSCALE_PUBLIC_PROBE_SEQUENCE;
523
+ if (fakeProbeSequencePath) {
524
+ const source = fs.existsSync(fakeProbeSequencePath)
525
+ ? fs.readFileSync(fakeProbeSequencePath, "utf8")
526
+ : "";
527
+ const entries = source
528
+ .split(/\r?\n/)
529
+ .map((entry) => entry.trim())
530
+ .filter(Boolean);
531
+ const current = entries.shift() ?? "fail";
532
+ fs.writeFileSync(fakeProbeSequencePath, entries.length ? `${entries.join("\n")}\n` : "");
533
+ return current === "ok"
534
+ ? { ok: true, fake: true }
535
+ : { ok: false, error: current === "fail" ? "fake probe failure" : current };
536
+ }
537
+ if (
538
+ ["1", "true", "yes"].includes(
539
+ String(process.env.FORGE_MEMORY_SKIP_TAILSCALE_PUBLIC_PROBE ?? "").toLowerCase()
540
+ )
541
+ ) {
542
+ return { ok: true, skipped: true };
543
+ }
544
+ const controller = new AbortController();
545
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
546
+ try {
547
+ const response = await fetch(forgePublicHealthUrl(publicUrl), {
548
+ headers: { accept: "application/json", "x-forge-runtime-probe": "1" },
549
+ signal: controller.signal
550
+ });
551
+ if (!response.ok) return { ok: false, status: response.status };
552
+ const payload = await response.json().catch(() => null);
553
+ if (!isForgeHealthPayload(payload)) {
554
+ return { ok: false, status: response.status, error: "non-Forge health payload" };
555
+ }
556
+ return { ok: true, status: response.status };
557
+ } catch (error) {
558
+ return { ok: false, error: describeNetworkError(error) };
559
+ } finally {
560
+ clearTimeout(timeout);
561
+ }
562
+ }
563
+
564
+ async function configureTailscaleServe(config, flags) {
565
+ const target = `http://127.0.0.1:${config.port || DEFAULT_PORT}`;
566
+ return runCommand("tailscale", ["serve", "--bg", target], {
567
+ dryRun: flags?.dryRun
568
+ });
569
+ }
570
+
571
+ function tailscalePreferredMessage() {
572
+ return [
573
+ "Tailscale is preferred when available: it gives the iPhone a normal HTTPS Forge URL, is usually faster and steadier than relayed Iroh, and avoids the custom WebView URL scheme.",
574
+ "Iroh remains the fallback when Tailscale is missing, declined, or unreachable."
575
+ ].join(" ");
576
+ }
577
+
365
578
  function refreshCargoPath() {
366
579
  const cargoBin = path.join(homeDir(), ".cargo", "bin");
367
580
  if (fs.existsSync(cargoBin)) {
@@ -1969,7 +2182,12 @@ async function createPairing(config, options = {}) {
1969
2182
  cookie: operatorCookie,
1970
2183
  ...(publicUrl ? { referer: publicUrl } : {})
1971
2184
  },
1972
- body: JSON.stringify({ userId: null, transportMode })
2185
+ body: JSON.stringify({
2186
+ userId: null,
2187
+ transportMode,
2188
+ fallbackMode: publicUrl ? publicUrlFallbackMode(publicUrl) : "none",
2189
+ publicUrl: publicUrl ?? undefined
2190
+ })
1973
2191
  });
1974
2192
  } catch (error) {
1975
2193
  const healthResult = await health(config, 1_500);
@@ -2007,6 +2225,188 @@ async function createPairing(config, options = {}) {
2007
2225
  return pairing;
2008
2226
  }
2009
2227
 
2228
+ async function maybeInstallTailscaleForPairing(parsed) {
2229
+ const plan = tailscaleInstallPlan();
2230
+ if (parsed.flags.json || parsed.flags.dryRun || !process.stdin.isTTY) {
2231
+ return { installed: false, guidance: plan.guidance };
2232
+ }
2233
+ console.log(color.bold("Tailscale pairing"));
2234
+ console.log(tailscalePreferredMessage());
2235
+ console.log(color.dim("Tailscale is not installed or not visible on PATH."));
2236
+ for (const item of plan.guidance) console.log(color.dim(`- ${item}`));
2237
+ if (!plan.installable || !plan.autoInstallCommand) {
2238
+ return { installed: false, guidance: plan.guidance };
2239
+ }
2240
+ const shouldInstall = await promptYesNo(
2241
+ "Install Tailscale now before falling back to Iroh?",
2242
+ true
2243
+ );
2244
+ if (!shouldInstall) return { installed: false, guidance: plan.guidance };
2245
+ const result = await runCommand(plan.autoInstallCommand.command, plan.autoInstallCommand.args);
2246
+ return { installed: result.ok, guidance: plan.guidance, result };
2247
+ }
2248
+
2249
+ async function resolveTailscalePairingOptions(parsed, config) {
2250
+ const state = detectTailscaleState();
2251
+ if (state.disabled) return null;
2252
+ if (!state.installed) {
2253
+ const installAttempt = await maybeInstallTailscaleForPairing(parsed);
2254
+ if (installAttempt.installed) {
2255
+ const installedState = detectTailscaleState();
2256
+ if (installedState.installed && installedState.running && installedState.authenticated) {
2257
+ return resolveTailscalePairingOptions(parsed, config);
2258
+ }
2259
+ }
2260
+ return null;
2261
+ }
2262
+ if (!state.running || !state.authenticated || !state.publicUrl) {
2263
+ if (!parsed.flags.json && process.stdin.isTTY) {
2264
+ console.log(color.bold("Tailscale pairing"));
2265
+ console.log(tailscalePreferredMessage());
2266
+ console.log(
2267
+ color.yellow(
2268
+ "Tailscale is installed, but it is not running/authenticated or does not expose a MagicDNS name."
2269
+ )
2270
+ );
2271
+ console.log(color.dim("Open Tailscale, sign in, then rerun npx forge-memory pair-ios. Falling back to Iroh for this pairing."));
2272
+ }
2273
+ return null;
2274
+ }
2275
+
2276
+ const firstProbe = await probePublicForgeUrl(state.publicUrl);
2277
+ if (firstProbe.ok) {
2278
+ if (!parsed.flags.json) {
2279
+ console.log(color.green(`Using Tailscale for iOS pairing: ${state.publicUrl}`));
2280
+ }
2281
+ return {
2282
+ transportMode: "manual-http",
2283
+ publicUrl: validatePairingOptions({
2284
+ transportMode: "manual-http",
2285
+ publicUrl: state.publicUrl
2286
+ }),
2287
+ source: "tailscale"
2288
+ };
2289
+ }
2290
+
2291
+ const canConfigureServe =
2292
+ !parsed.flags.json &&
2293
+ !parsed.flags.dryRun &&
2294
+ (parsed.flags.yes ||
2295
+ (process.stdin.isTTY &&
2296
+ (await promptYesNo(
2297
+ [
2298
+ `Tailscale is running, but Forge is not reachable at ${state.publicUrl}.`,
2299
+ "Serve Forge through Tailscale now?"
2300
+ ].join(" "),
2301
+ true
2302
+ ))));
2303
+ if (!canConfigureServe) {
2304
+ if (!parsed.flags.json && process.stdin.isTTY) {
2305
+ console.log(
2306
+ color.yellow(
2307
+ `Tailscale is running, but Forge was not reachable at ${state.publicUrl}: ${firstProbe.error ?? firstProbe.status ?? "unknown error"}. Falling back to Iroh.`
2308
+ )
2309
+ );
2310
+ }
2311
+ return null;
2312
+ }
2313
+
2314
+ const serveResult = await configureTailscaleServe(config, parsed.flags);
2315
+ if (!serveResult.ok) {
2316
+ if (!parsed.flags.json) {
2317
+ console.log(
2318
+ color.yellow(
2319
+ "Could not configure Tailscale Serve automatically. Falling back to Iroh."
2320
+ )
2321
+ );
2322
+ }
2323
+ return null;
2324
+ }
2325
+ const secondProbe = await probePublicForgeUrl(state.publicUrl, 8_000);
2326
+ if (!secondProbe.ok) {
2327
+ if (!parsed.flags.json) {
2328
+ console.log(
2329
+ color.yellow(
2330
+ `Tailscale Serve was configured, but Forge still was not reachable at ${state.publicUrl}: ${secondProbe.error ?? secondProbe.status ?? "unknown error"}. Falling back to Iroh.`
2331
+ )
2332
+ );
2333
+ }
2334
+ return null;
2335
+ }
2336
+ if (!parsed.flags.json) {
2337
+ console.log(color.green(`Using Tailscale for iOS pairing: ${state.publicUrl}`));
2338
+ }
2339
+ return {
2340
+ transportMode: "manual-http",
2341
+ publicUrl: validatePairingOptions({
2342
+ transportMode: "manual-http",
2343
+ publicUrl: state.publicUrl
2344
+ }),
2345
+ source: "tailscale"
2346
+ };
2347
+ }
2348
+
2349
+ async function resolveIosPairingOptions(parsed, config = null) {
2350
+ if (parsed.flags.manualHttp) {
2351
+ return {
2352
+ transportMode: "manual-http",
2353
+ publicUrl: validatePairingOptions({
2354
+ transportMode: "manual-http",
2355
+ publicUrl: parsed.values.publicUrl
2356
+ })
2357
+ };
2358
+ }
2359
+ if (parsed.values.publicUrl) {
2360
+ return {
2361
+ transportMode: "manual-http",
2362
+ publicUrl: validatePairingOptions({
2363
+ transportMode: "manual-http",
2364
+ publicUrl: parsed.values.publicUrl
2365
+ }),
2366
+ source: publicUrlFallbackMode(parsed.values.publicUrl)
2367
+ };
2368
+ }
2369
+ if (config) {
2370
+ const tailscalePairing = await resolveTailscalePairingOptions(parsed, config);
2371
+ if (tailscalePairing) return tailscalePairing;
2372
+ }
2373
+ if (parsed.flags.yes || parsed.flags.json || !process.stdin.isTTY) {
2374
+ return {
2375
+ transportMode: "iroh",
2376
+ publicUrl: validatePairingOptions({
2377
+ transportMode: "iroh",
2378
+ publicUrl: parsed.values.publicUrl
2379
+ })
2380
+ };
2381
+ }
2382
+ console.log(color.bold("iOS companion connection"));
2383
+ console.log(color.dim(tailscalePreferredMessage()));
2384
+ console.log(color.dim("Choose Iroh or a fixed/private IP fallback for this pairing."));
2385
+ const choice = (
2386
+ await promptLine("Connection [iroh/ip]", "iroh")
2387
+ ).toLowerCase();
2388
+ if (choice === "ip" || choice === "fixed" || choice === "private") {
2389
+ const publicUrl = await promptLine(
2390
+ "Private/fixed Forge URL",
2391
+ parsed.values.publicUrl ?? "http://192.168.1.98:4317/forge/"
2392
+ );
2393
+ return {
2394
+ transportMode: "manual-http",
2395
+ publicUrl: validatePairingOptions({
2396
+ transportMode: "manual-http",
2397
+ publicUrl
2398
+ })
2399
+ };
2400
+ }
2401
+ return {
2402
+ transportMode: "iroh",
2403
+ publicUrl: validatePairingOptions({
2404
+ transportMode: "iroh",
2405
+ publicUrl: parsed.values.publicUrl
2406
+ })
2407
+ };
2408
+ }
2409
+
2010
2410
  function assertPairingTransportUsable(pairing, { requestedTransportMode }) {
2011
2411
  const payload = pairing?.qrPayload;
2012
2412
  if (!payload || requestedTransportMode !== "iroh") {
@@ -2015,6 +2415,23 @@ function assertPairingTransportUsable(pairing, { requestedTransportMode }) {
2015
2415
  const resolvedTransportMode = payload.transportMode ?? payload.transport?.protocol;
2016
2416
  const resolvedProtocol = payload.transport?.protocol;
2017
2417
  if (resolvedTransportMode === "iroh" || resolvedProtocol === "iroh") {
2418
+ const phoneFacingUrls = [
2419
+ payload.apiBaseUrl,
2420
+ payload.uiBaseUrl,
2421
+ payload.transport?.publicBaseUrl
2422
+ ].filter(Boolean);
2423
+ const loopbackUrl = phoneFacingUrls.find((url) => isLoopbackPairingUrl(url));
2424
+ if (loopbackUrl) {
2425
+ throw new PairingTransportUnavailableError(
2426
+ [
2427
+ "Forge created an Iroh pairing that exposes a loopback URL as phone-facing pairing data.",
2428
+ `Bad URL: ${loopbackUrl}.`,
2429
+ "A physical iPhone cannot reach localhost on this Mac.",
2430
+ "Use Iroh logical URLs, a selected Tailscale URL, or a selected private/fixed IP URL."
2431
+ ].join(" "),
2432
+ { apiBaseUrl: payload.apiBaseUrl, transportMode: resolvedTransportMode, protocol: resolvedProtocol }
2433
+ );
2434
+ }
2018
2435
  return;
2019
2436
  }
2020
2437
  const apiBaseUrl = payload.apiBaseUrl ?? "";
@@ -2039,6 +2456,15 @@ function assertPairingTransportUsable(pairing, { requestedTransportMode }) {
2039
2456
 
2040
2457
  function validatePairingOptions({ transportMode, publicUrl }) {
2041
2458
  const normalizedPublicUrl = normalizePublicPairingUrl(publicUrl);
2459
+ if (normalizedPublicUrl && isLoopbackPairingUrl(normalizedPublicUrl)) {
2460
+ throw new Error(
2461
+ [
2462
+ `--public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
2463
+ "A physical iPhone cannot reach localhost on this Mac.",
2464
+ "Use Iroh, Tailscale, or a private/fixed IP URL."
2465
+ ].join(" ")
2466
+ );
2467
+ }
2042
2468
  if (transportMode !== "manual-http") {
2043
2469
  return normalizedPublicUrl;
2044
2470
  }
@@ -2052,15 +2478,6 @@ function validatePairingOptions({ transportMode, publicUrl }) {
2052
2478
  ].join(" ")
2053
2479
  );
2054
2480
  }
2055
- if (isLoopbackPairingUrl(normalizedPublicUrl)) {
2056
- throw new Error(
2057
- [
2058
- `Manual HTTP --public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
2059
- "A physical iPhone cannot reach localhost on this Mac.",
2060
- "Use a Tailscale or LAN URL, or omit --manual-http and use Iroh pairing."
2061
- ].join(" ")
2062
- );
2063
- }
2064
2481
  return normalizedPublicUrl;
2065
2482
  }
2066
2483
 
@@ -2290,19 +2707,6 @@ async function runInstall(parsed, command) {
2290
2707
  (parsed.flags.yes
2291
2708
  ? true
2292
2709
  : await promptYesNo("Pair the iOS companion now?", true)));
2293
- let irohTransportResult = null;
2294
- if (
2295
- shouldPair &&
2296
- !parsed.flags.manualHttp &&
2297
- !parsed.flags.dryRun
2298
- ) {
2299
- irohTransportResult = await withProgress(
2300
- "Preparing Forge Companion Iroh transport",
2301
- "checking Rust/Cargo and building the local host",
2302
- parsed.flags,
2303
- () => ensureIrohTransportPrepared(config, parsed.flags)
2304
- );
2305
- }
2306
2710
  let runtimeResult = null;
2307
2711
  if (!parsed.flags.noStart && !parsed.flags.dryRun) {
2308
2712
  runtimeResult = await withProgress(
@@ -2340,6 +2744,22 @@ async function runInstall(parsed, command) {
2340
2744
  }
2341
2745
  }
2342
2746
  }
2747
+ const pairingOptions = shouldPair
2748
+ ? await resolveIosPairingOptions(parsed, config)
2749
+ : null;
2750
+ let irohTransportResult = null;
2751
+ if (
2752
+ shouldPair &&
2753
+ pairingOptions?.transportMode !== "manual-http" &&
2754
+ !parsed.flags.dryRun
2755
+ ) {
2756
+ irohTransportResult = await withProgress(
2757
+ "Preparing Forge Companion Iroh transport",
2758
+ "checking Rust/Cargo and building the local host",
2759
+ parsed.flags,
2760
+ () => ensureIrohTransportPrepared(config, parsed.flags)
2761
+ );
2762
+ }
2343
2763
  let pairing = null;
2344
2764
  if (shouldPair && !parsed.flags.dryRun) {
2345
2765
  if (!runtimeResult) {
@@ -2353,12 +2773,12 @@ async function runInstall(parsed, command) {
2353
2773
  assertRuntimeStartedForPairing(runtimeResult, config);
2354
2774
  pairing = await withProgress(
2355
2775
  "Creating iOS companion pairing",
2356
- parsed.flags.manualHttp ? "manual HTTP" : "Iroh QR",
2776
+ pairingOptions.transportMode === "manual-http" ? "phone-reachable HTTP" : "Iroh QR",
2357
2777
  parsed.flags,
2358
2778
  () =>
2359
2779
  createPairing(config, {
2360
- transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh",
2361
- publicUrl: parsed.values.publicUrl
2780
+ transportMode: pairingOptions.transportMode,
2781
+ publicUrl: pairingOptions.publicUrl
2362
2782
  })
2363
2783
  );
2364
2784
  if (pairing?.qrPayload && !parsed.flags.json) {
@@ -2584,12 +3004,15 @@ async function runUi(parsed) {
2584
3004
 
2585
3005
  async function runPairIos(parsed) {
2586
3006
  const config = await readConfig();
2587
- const transportMode = parsed.flags.manualHttp ? "manual-http" : "iroh";
2588
- const publicUrl = validatePairingOptions({
2589
- transportMode,
2590
- publicUrl: parsed.values.publicUrl
2591
- });
2592
- if (transportMode === "iroh") {
3007
+ const explicitPairingOptions =
3008
+ parsed.flags.manualHttp || parsed.values.publicUrl
3009
+ ? await resolveIosPairingOptions(parsed, config)
3010
+ : null;
3011
+ const noStartPairingOptions =
3012
+ !explicitPairingOptions && parsed.flags.noStart
3013
+ ? await resolveIosPairingOptions(parsed, config)
3014
+ : null;
3015
+ if (noStartPairingOptions?.transportMode === "iroh") {
2593
3016
  await withProgress(
2594
3017
  "Preparing Forge Companion Iroh transport",
2595
3018
  "checking Rust/Cargo and building the local host",
@@ -2612,6 +3035,20 @@ async function runPairIos(parsed) {
2612
3035
  config
2613
3036
  );
2614
3037
  }
3038
+ const pairingOptions =
3039
+ explicitPairingOptions ??
3040
+ noStartPairingOptions ??
3041
+ await resolveIosPairingOptions(parsed, config);
3042
+ const transportMode = pairingOptions.transportMode;
3043
+ const publicUrl = pairingOptions.publicUrl;
3044
+ if (transportMode === "iroh" && noStartPairingOptions?.transportMode !== "iroh") {
3045
+ await withProgress(
3046
+ "Preparing Forge Companion Iroh transport",
3047
+ "checking Rust/Cargo and building the local host",
3048
+ parsed.flags,
3049
+ () => ensureIrohTransportPrepared(config, parsed.flags)
3050
+ );
3051
+ }
2615
3052
  const pairing = await createPairing(config, {
2616
3053
  transportMode,
2617
3054
  publicUrl
@@ -2951,8 +3388,8 @@ Options:
2951
3388
  --adapters <list> Comma list: openclaw,hermes,codex or none
2952
3389
  --skip-adapters Configure UI/runtime only
2953
3390
  --skip-pair-ios Do not prompt or create iOS pairing
2954
- --manual-http Use direct HTTP/TCP for iOS pairing instead of the default Iroh transport
2955
- --public-url <url> Phone-reachable URL for manual HTTP pairing, such as a Tailscale or LAN Forge URL
3391
+ --manual-http Force direct HTTP/TCP for iOS pairing
3392
+ --public-url <url> Phone-facing Tailscale/LAN/fixed URL for direct pairing; never localhost
2956
3393
  --no-start Configure without starting runtime
2957
3394
  --no-doctor Skip install-time doctor checks
2958
3395
  --repair Let doctor create missing folders and restart unhealthy runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.117",
3
+ "version": "0.3.0",
4
4
  "description": "Guided Forge installer and local runtime manager for the Forge UI, OpenClaw, Hermes, Codex, and iOS pairing.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",