Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 87 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,24 @@ npx opencode-mobile install

### Step 2: Start OpenCode

The plugin only initializes its tunnel and LAN server when OpenCode is launched
in **serve** mode. Use `--hostname 0.0.0.0` so your phone can reach it over LAN
(the default `127.0.0.1` is loopback-only).

```bash
opencode attach
opencode serve --hostname 0.0.0.0 --port 4096
```

Or start a new session:
If you want a TUI, attach in a second terminal:

```bash
opencode serve
opencode attach
```

> ⚠️ Running plain `opencode` (or `opencode attach` alone) will load the plugin
> but skip tunnel/LAN-server startup. `/mobile` will respond with
> "No tunnel URL found" until you start a `serve` instance.

**What you'll see:**
```
[opencode-mobile] v1.3.10
Expand Down Expand Up @@ -129,10 +137,22 @@ https://your-tunnel-url.ngrok.io

| Variable | Description | Default |
|----------|-------------|---------|
| `TUNNEL_PROVIDER` | Tunnel provider (`auto`, `ngrok`, `cloudflare`, `localtunnel`) | `auto` |
| `TUNNEL_PROVIDER` | Tunnel provider (`auto`, `ngrok`, `cloudflare`, `localtunnel`). Overrides persisted config. | persisted config, else `auto` |
| `OPENCODE_MOBILE_DEBUG` | Enable debug logging (`1` to enable) | disabled |
| `OPENCODE_PORT` | Local server port | `3000` |

### Persisted Tunnel Config

When you run `npx opencode-mobile install --provider <name>` (or
`npx opencode-mobile-tunnel-setup`), the choice is persisted to:

```
~/.config/opencode-mobile/tunnel-config.json
```

The plugin reads this file at runtime, so you don't need to set
`TUNNEL_PROVIDER` every time. The env var still wins if both are present.

### Tunnel Providers

The plugin automatically tries providers in this order:
Expand Down Expand Up @@ -170,11 +190,21 @@ ngrok config add-authtoken YOUR_TOKEN

### "No tunnel URL found"

**Problem**: Tunnel failed to start
**Problem**: Tunnel failed to start, or you're not in `serve` mode

**The plugin only auto-starts the tunnel when OpenCode is launched in `serve` mode.**
If you ran the plain `opencode` TUI (or `opencode attach`), `/mobile` will report
"No tunnel URL found" because the LAN server and tunnel were never initialized.

**Solutions:**
```bash
# Check tunnel provider is installed
# Start OpenCode with LAN access enabled (required for phone-on-LAN access)
opencode serve --hostname 0.0.0.0 --port 4096

# Then in another terminal, attach a TUI if you want one:
opencode attach

# Verify your tunnel provider is installed
command -v ngrok
command -v cloudflared

Expand All @@ -185,6 +215,57 @@ npx opencode-mobile-tunnel-setup
npx opencode-mobile install --skip-tunnel-setup
```

> ⚠️ `opencode serve` defaults to `--hostname 127.0.0.1` (loopback only). Phones
> on your LAN cannot reach 127.0.0.1. **Always use `--hostname 0.0.0.0`** unless
> you are relying solely on the public tunnel.

### Mobile permission approvals don't reflect on the PC

**Problem**: Tapping "Always allow" / "Approve" in the mobile app doesn't update
OpenCode on your computer.

**How the flow works:**
1. OpenCode emits a `permission.asked` event when a tool needs approval.
2. The plugin formats it into a push notification with `permissionId`,
`sessionId`, and the tunnel `serverUrl` in the payload.
3. The mobile app is expected to POST the approval back to OpenCode's REST API
via the tunnel URL, e.g.
`POST {serverUrl}/session/{sessionId}/permissions/{permissionId}` with
`{"response": "always"}` (exact path/payload depends on the OpenCode version
running on your PC).

**Solutions / diagnostics:**

```bash
# 1. Confirm OpenCode is reachable through the tunnel from your phone.
# Open the tunnel URL in your phone's browser - you should hit OpenCode's API.

# 2. Make sure OpenCode is bound to 0.0.0.0, NOT 127.0.0.1.
# A loopback-only bind works for tunnels running in-process, but breaks
# direct LAN connections from the mobile app.
opencode serve --hostname 0.0.0.0 --port 4096

# 3. Enable plugin debug logging to see permission events as they're emitted.
$env:OPENCODE_MOBILE_DEBUG = "1" # PowerShell
# or export OPENCODE_MOBILE_DEBUG=1 # bash

# 4. Tail OpenCode's own server logs while you tap "Approve" on the phone -
# if no incoming request appears, the mobile app never reached the server
# (tunnel issue / DNS / firewall).

# 5. Test the approve endpoint manually from your machine using the tunnel URL
# (replace IDs with values from a real notification payload):
curl -X POST "https://<tunnel-url>/session/<sessionId>/permissions/<permissionId>" \
-H "Content-Type: application/json" \
-d '{"response":"always"}'
```

If the manual `curl` works but the mobile app's tap does not, the bug is on the
mobile-app side. If the manual `curl` also fails, check:
- Tunnel provider is up (visit `${tunnelUrl}/` in a browser)
- OpenCode is bound to `0.0.0.0` and the tunnel is pointing at the correct port
- Your firewall isn't blocking the port

### "Push token not registering"

**Problem**: Device can't reach the plugin server
Expand Down
76 changes: 68 additions & 8 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ debugLog("LAN-only architecture: plugin handles push tokens, tunnel goes to Open
debugLog("[PushPlugin][Mobile] Entry loaded: index.ts");

import * as path from "path";
import * as os from "os";
import http from "http";

import { tool } from "@opencode-ai/plugin";
Expand Down Expand Up @@ -86,14 +87,55 @@ const VALID_PROVIDERS = ["cloudflare", "ngrok", "localtunnel", "auto"] as const;
type TunnelProvider = typeof VALID_PROVIDERS[number];

/**
* Get the configured tunnel provider from environment variable
* Defaults to 'auto' if not set or invalid
* Path to persisted tunnel config (written by `opencode-mobile install --provider ...`
* and `opencode-mobile-tunnel-setup`).
*/
const TUNNEL_CONFIG_FILE = path.join(
os.homedir(),
".config",
"opencode-mobile",
"tunnel-config.json",
);

/**
* Read the persisted tunnel-config.json, if any.
* Returns null when missing/unreadable so callers can fall back to defaults.
*/
function loadPersistedTunnelConfig(): { provider?: string } | null {
try {
if (!fs.existsSync(TUNNEL_CONFIG_FILE)) return null;
const raw = fs.readFileSync(TUNNEL_CONFIG_FILE, "utf-8");
const data = JSON.parse(raw) as { provider?: unknown };
if (typeof data.provider === "string") {
return { provider: data.provider };
}
return null;
} catch {
return null;
}
}

/**
* Get the configured tunnel provider.
*
* Resolution order:
* 1. TUNNEL_PROVIDER env var (overrides everything)
* 2. ~/.config/opencode-mobile/tunnel-config.json (written by install/tunnel-setup)
* 3. 'auto'
*/
function getTunnelProvider(): TunnelProvider {
const provider = process.env.TUNNEL_PROVIDER?.toLowerCase() as TunnelProvider;
if (VALID_PROVIDERS.includes(provider)) {
return provider;
const envProvider = process.env.TUNNEL_PROVIDER?.toLowerCase() as TunnelProvider;
if (VALID_PROVIDERS.includes(envProvider)) {
return envProvider;
}

const persisted = loadPersistedTunnelConfig();
const persistedProvider = persisted?.provider?.toLowerCase() as TunnelProvider;
if (persistedProvider && VALID_PROVIDERS.includes(persistedProvider)) {
debugLog(`[Tunnel] Using persisted provider from ${TUNNEL_CONFIG_FILE}: ${persistedProvider}`);
return persistedProvider;
}

return "auto";
}

Expand Down Expand Up @@ -456,7 +498,18 @@ const mobileTool = tool({
const url = rawUrl || urlFromPath || metadataUrl || "";

if (!url || !url.startsWith("http")) {
return "No tunnel URL found.";
return [
"No tunnel URL found.",
"",
"The mobile tunnel only auto-starts when OpenCode is launched in `serve` mode.",
"If you started OpenCode with the plain `opencode` TUI (or `opencode attach`), the",
"plugin's LAN server and tunnel were not initialized.",
"",
"Start the server with LAN access enabled:",
" opencode serve --hostname 0.0.0.0 --port 4096",
"",
"Then run `/mobile` again to display the QR code.",
].join("\n");
}

const qr = await generateQRCodeAsciiPlain(url);
Expand Down Expand Up @@ -768,8 +821,15 @@ export const PushNotificationPlugin: Plugin = async (ctx) => {

if (!isServeMode) {
console.log(
"[opencode-mobile] Plugin init OK; skipping (not in 'serve' mode). " +
"Run: opencode serve ...",
"[opencode-mobile] Plugin loaded in non-serve mode (TUI/attach). " +
"Tunnel + LAN server are NOT started here.",
);
console.log(
"[opencode-mobile] To enable mobile features (QR + push), restart OpenCode with:",
);
console.log("[opencode-mobile] opencode serve --hostname 0.0.0.0 --port 4096");
console.log(
"[opencode-mobile] (--hostname 0.0.0.0 is required so your phone can reach the server over LAN.)",
);
return {
tool: {
Expand Down
77 changes: 75 additions & 2 deletions src/cli/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,67 @@ import { installPluginToGlobalOpenCodeConfig, installGlobalCommand } from "./ope
import { MOBILE_COMMAND_NAME, getMobileCommandMarkdown } from "./mobile-command.js";
import { checkForUpdates, executeUpdate } from "./version-check.js";
import { spawn } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as url from "url";
import { createInterface } from "readline";

const PLUGIN_SPEC = "opencode-mobile@latest";

const VALID_PROVIDERS = ["cloudflare", "ngrok", "localtunnel", "auto", "none"] as const;

const TUNNEL_CONFIG_DIR = path.join(os.homedir(), ".config", "opencode-mobile");
const TUNNEL_CONFIG_FILE = path.join(TUNNEL_CONFIG_DIR, "tunnel-config.json");

/**
* Persist the user's --provider choice so the plugin can read it at runtime.
*
* Writing here is intentionally additive: tunnel-setup also writes this file with
* provider-specific extras (cloudflaredPath, mode, etc). We only persist a minimal
* `{ provider }` record when the install command itself is invoked with --provider
* but tunnel-setup didn't run (e.g. --skip-tunnel-setup, or "none").
*/
function persistProviderChoice(provider: string, dryRun: boolean): void {
const normalized = provider.toLowerCase();
if (!VALID_PROVIDERS.includes(normalized as (typeof VALID_PROVIDERS)[number])) {
return;
}
if (normalized === "none") {
return;
}
if (dryRun) {
console.log(`[Dry Run] Would persist tunnel provider "${normalized}" to ${TUNNEL_CONFIG_FILE}`);
return;
}

try {
if (!fs.existsSync(TUNNEL_CONFIG_DIR)) {
fs.mkdirSync(TUNNEL_CONFIG_DIR, { recursive: true });
}

let existing: Record<string, unknown> = {};
if (fs.existsSync(TUNNEL_CONFIG_FILE)) {
try {
existing = JSON.parse(fs.readFileSync(TUNNEL_CONFIG_FILE, "utf-8")) as Record<string, unknown>;
} catch {
existing = {};
}
}

const merged = { ...existing, provider: normalized };
fs.writeFileSync(TUNNEL_CONFIG_FILE, JSON.stringify(merged, null, 2));
try {
fs.chmodSync(TUNNEL_CONFIG_FILE, 0o600);
} catch {
// Ignore permission errors on Windows
}
console.log(`✅ Persisted tunnel provider "${normalized}" to ${TUNNEL_CONFIG_FILE}`);
} catch (error) {
console.error("⚠️ Failed to persist tunnel provider choice:", error instanceof Error ? error.message : error);
}
}

type InstallCliOptions = {
help: boolean;
dryRun: boolean;
Expand Down Expand Up @@ -244,7 +299,25 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
await runTunnelSetup(options);
}

// Persist --provider choice even if tunnel-setup didn't run (e.g. --skip-tunnel-setup
// or provider === "none"). This ensures the runtime plugin can pick it up via
// ~/.config/opencode-mobile/tunnel-config.json without needing TUNNEL_PROVIDER env var.
if (options.provider) {
persistProviderChoice(options.provider, options.dryRun);
}

console.log(`${prefix}\n🎉 Installation complete!`);
console.log(`${prefix} Restart OpenCode (run \`opencode\`) to load the plugin.`);
console.log(`${prefix} Use \`/mobile\` in any project to access mobile features.`);
console.log(`${prefix}`);
console.log(`${prefix}⚠️ IMPORTANT: Mobile features only work in \`opencode serve\` mode.`);
console.log(`${prefix} The plain \`opencode\` TUI does NOT auto-start the tunnel, and`);
console.log(`${prefix} \`/mobile\` will respond with "No tunnel URL found".`);
console.log(`${prefix}`);
console.log(`${prefix} Start OpenCode with LAN access enabled so your phone can reach it:`);
console.log(`${prefix} opencode serve --hostname 0.0.0.0 --port 4096`);
console.log(`${prefix}`);
console.log(`${prefix} The default --hostname is 127.0.0.1, which is loopback-only and`);
console.log(`${prefix} not reachable from a phone on your LAN. Use 0.0.0.0 to bind all`);
console.log(`${prefix} interfaces. (Make sure your firewall allows the chosen port.)`);
console.log(`${prefix}`);
console.log(`${prefix} Then use \`/mobile\` in any project to display the QR code.`);
}