clawdex-mobile 5.0.5-internal.9 → 5.0.5

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
@@ -38,7 +38,8 @@ npm install -g clawdex-mobile@latest
38
38
  clawdex init
39
39
  ```
40
40
 
41
- Then open the mobile app and scan the pairing QR.
41
+ Then open the mobile app and connect using the printed bridge URL/token.
42
+ `clawdex init` now writes config, starts the bridge in the background, and returns you to the shell. Bridge logs go to `.bridge.log`.
42
43
 
43
44
  The npm package is bridge-only. It does not install Expo or the mobile source tree. On supported macOS, Linux, and Windows hosts it uses bundled bridge binaries, so normal startup does not compile Rust.
44
45
  The current interactive setup helpers are still macOS/Linux-oriented.
package/bin/clawdex.js CHANGED
@@ -12,7 +12,7 @@ function printUsage() {
12
12
  Commands:
13
13
  init [--no-start] [--engine codex|opencode]
14
14
  Run interactive bridge onboarding and secure setup.
15
- By default, this also starts the secure bridge in the foreground.
15
+ By default, this also starts the secure bridge in the background.
16
16
  Use --no-start to configure only.
17
17
  Use --engine to set the preferred backend written to .env.secure.
18
18
 
@@ -27,9 +27,9 @@ If you want only one harness, use `--engine codex` or `--engine opencode`.
27
27
  After `clawdex init`, expected sequence:
28
28
 
29
29
  1. Secure config is written or reused
30
- 2. The bridge starts in the foreground
31
- 3. A pairing QR is printed for the mobile app
32
- 4. Bridge logs stay attached until you stop the process
30
+ 2. The bridge starts in the background
31
+ 3. The wizard prints the bridge URL/token for manual mobile pairing
32
+ 4. Bridge logs are written to `.bridge.log`
33
33
 
34
34
  Published npm releases bundle prebuilt bridge binaries for `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linux-armv7l`, and `win32-x64`. On those hosts, normal bridge startup does not require a Rust compile.
35
35
 
@@ -160,6 +160,18 @@ npm run teardown -- --yes
160
160
  | `EXPO_PUBLIC_ALLOW_INSECURE_REMOTE_BRIDGE` | suppress insecure-HTTP warning |
161
161
  | `EXPO_PUBLIC_PRIVACY_POLICY_URL` | in-app Privacy link |
162
162
  | `EXPO_PUBLIC_TERMS_OF_SERVICE_URL` | in-app Terms link |
163
+ | `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` | RevenueCat public SDK key for iOS tip purchases |
164
+ | `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY` | RevenueCat public SDK key for Android tip purchases |
165
+ | `EXPO_PUBLIC_REVENUECAT_TEST_STORE_API_KEY` | RevenueCat Test Store public SDK key for Expo Go / Store Client tip testing |
166
+ | `EXPO_PUBLIC_REVENUECAT_TIPS_OFFERING_ID` | optional RevenueCat offering identifier for the tip jar (`current` if omitted) |
167
+
168
+ If you enable the optional tip jar:
169
+
170
+ - Configure 4–5 non-subscription products in RevenueCat and attach them to a dedicated Offering
171
+ - Use consumables for repeatable “tip” tiers
172
+ - Enable In-App Purchase for the app’s Apple bundle identifier in App Store Connect / Apple Developer
173
+ - Use the RevenueCat Test Store SDK key in Expo Go; use the real iOS SDK key only in native builds/TestFlight/App Store builds
174
+ - Rebuild the native app after adding `react-native-purchases`
163
175
 
164
176
  ## Production Readiness Checklist
165
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawdex-mobile",
3
- "version": "5.0.5-internal.9",
3
+ "version": "5.0.5",
4
4
  "description": "Private-network mobile bridge and CLI for Codex and OpenCode",
5
5
  "keywords": [
6
6
  "codex",
@@ -233,6 +233,11 @@ load_existing_engine_selection() {
233
233
  local raw=""
234
234
  local engine=""
235
235
 
236
+ if [[ "$ENGINE_SELECTION_PRESET" == "true" ]]; then
237
+ sync_active_engine_from_selection
238
+ return 0
239
+ fi
240
+
236
241
  raw="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")"
237
242
  if [[ -n "$raw" ]] && parse_existing_engine_list_csv "$raw"; then
238
243
  engine="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
@@ -951,6 +956,8 @@ print_existing_setup_summary() {
951
956
  local network_mode=""
952
957
  local harnesses=""
953
958
  local source_path=""
959
+ local saved_active_engine="$ACTIVE_ENGINE"
960
+ local -a saved_selected_engines=("${SELECTED_ENGINES[@]}")
954
961
 
955
962
  if [[ ! -f "$SECURE_ENV_FILE" ]]; then
956
963
  return 1
@@ -991,6 +998,9 @@ print_existing_setup_summary() {
991
998
  echo "bridge.token: present"
992
999
  fi
993
1000
  echo "source: $source_path"
1001
+
1002
+ SELECTED_ENGINES=("${saved_selected_engines[@]}")
1003
+ ACTIVE_ENGINE="$saved_active_engine"
994
1004
  }
995
1005
 
996
1006
  require_security_ack() {
@@ -1556,13 +1566,12 @@ confirm_phone_network_ready() {
1556
1566
  esac
1557
1567
  }
1558
1568
 
1559
- start_bridge_foreground() {
1560
- rail_echo "Starting bridge in foreground."
1561
- rail_echo "Press Ctrl+C to stop the bridge."
1569
+ start_bridge_background() {
1570
+ rail_echo "Starting bridge in background."
1562
1571
  echo ""
1563
1572
  (
1564
1573
  cd "$ROOT_DIR"
1565
- npm run secure:bridge
1574
+ node "$SCRIPT_DIR/start-bridge-secure.js" --background
1566
1575
  )
1567
1576
  }
1568
1577
 
@@ -1691,6 +1700,7 @@ fi
1691
1700
 
1692
1701
  BRIDGE_HOST="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_HOST")"
1693
1702
  BRIDGE_PORT="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_PORT")"
1703
+ BRIDGE_TOKEN="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_AUTH_TOKEN")"
1694
1704
  NETWORK_MODE="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_NETWORK_MODE")"
1695
1705
  if [[ -z "$NETWORK_MODE" ]]; then
1696
1706
  NETWORK_MODE="$(infer_network_mode_from_host "$BRIDGE_HOST")"
@@ -1710,16 +1720,25 @@ fi
1710
1720
  section "Hatch"
1711
1721
  if [[ "$AUTO_START" == "true" ]]; then
1712
1722
  rail_echo "Auto-start enabled."
1713
- rail_echo "Launching bridge in foreground..."
1714
- start_bridge_foreground
1715
- exit $?
1723
+ rail_echo "Launching bridge in background..."
1724
+ start_bridge_background || exit $?
1716
1725
  else
1717
1726
  rail_echo "Auto-start disabled by --no-start."
1718
1727
  rail_echo "Skipping bridge launch."
1719
1728
  fi
1720
1729
 
1721
1730
  section "Next steps"
1722
- rail_echo "1) cd $ROOT_DIR && npm run secure:bridge"
1723
- rail_echo "2) Open the mobile app and use onboarding to connect (URL + token QR)."
1731
+ if [[ "$AUTO_START" == "true" ]]; then
1732
+ rail_echo "1) Open the mobile app and use onboarding to connect."
1733
+ rail_echo "Bridge URL: http://$BRIDGE_HOST:$BRIDGE_PORT"
1734
+ if [[ -n "$BRIDGE_TOKEN" ]]; then
1735
+ rail_echo "Bridge token: $BRIDGE_TOKEN"
1736
+ fi
1737
+ rail_echo "Bridge logs: $ROOT_DIR/.bridge.log"
1738
+ rail_echo "2) Stop the bridge later with: clawdex stop"
1739
+ else
1740
+ rail_echo "1) cd $ROOT_DIR && npm run secure:bridge"
1741
+ rail_echo "2) Open the mobile app and use onboarding to connect (URL + token QR)."
1742
+ fi
1724
1743
  rail_blank
1725
1744
  rail_echo "${DIM}You can rerun this anytime: npm run setup:wizard${RESET}"
@@ -3,6 +3,8 @@
3
3
 
4
4
  const { spawn, spawnSync } = require("node:child_process");
5
5
  const fs = require("node:fs");
6
+ const http = require("node:http");
7
+ const https = require("node:https");
6
8
  const os = require("node:os");
7
9
  const path = require("node:path");
8
10
 
@@ -13,6 +15,9 @@ const {
13
15
  resolveRuntimeTarget,
14
16
  } = require("./bridge-binary");
15
17
 
18
+ const DEFAULT_HEALTH_TIMEOUT_MS = 15000;
19
+ const DEV_HEALTH_TIMEOUT_MS = 60000;
20
+
16
21
  function resolveRootDir() {
17
22
  let rootDir = process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : path.resolve(__dirname, "..");
18
23
  if (!fs.existsSync(path.join(rootDir, "package.json"))) {
@@ -50,6 +55,103 @@ function readEnvFile(filePath) {
50
55
  return nextEnv;
51
56
  }
52
57
 
58
+ function sleep(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+
62
+ function formatHostForUrl(host) {
63
+ if (host.includes(":") && !host.startsWith("[")) {
64
+ return `[${host}]`;
65
+ }
66
+ return host;
67
+ }
68
+
69
+ function bridgePidFile(rootDir) {
70
+ return path.join(rootDir, ".bridge.pid");
71
+ }
72
+
73
+ function bridgeLogFile(rootDir) {
74
+ return path.join(rootDir, ".bridge.log");
75
+ }
76
+
77
+ function readPidFile(rootDir) {
78
+ try {
79
+ const raw = fs.readFileSync(bridgePidFile(rootDir), "utf8").trim();
80
+ const pid = Number.parseInt(raw, 10);
81
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function writePidFile(rootDir, pid) {
88
+ fs.writeFileSync(bridgePidFile(rootDir), `${pid}\n`);
89
+ }
90
+
91
+ function removePidFile(rootDir) {
92
+ try {
93
+ fs.unlinkSync(bridgePidFile(rootDir));
94
+ } catch {}
95
+ }
96
+
97
+ function isProcessAlive(pid) {
98
+ if (!pid) {
99
+ return false;
100
+ }
101
+ try {
102
+ process.kill(pid, 0);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ async function waitForHealth(env, pid, timeoutMs) {
110
+ const host = env.BRIDGE_HOST || "127.0.0.1";
111
+ const port = env.BRIDGE_PORT || "8787";
112
+ const url = new URL(`http://${formatHostForUrl(host)}:${port}/health`);
113
+ const startedAt = Date.now();
114
+
115
+ while (Date.now() - startedAt < timeoutMs) {
116
+ const ok = await probeHealth(url);
117
+
118
+ if (ok) {
119
+ if (!isProcessAlive(pid)) {
120
+ throw new Error("bridge health endpoint responded, but the started process already exited");
121
+ }
122
+ return { host, port };
123
+ }
124
+
125
+ if (!isProcessAlive(pid)) {
126
+ throw new Error("bridge process exited before becoming healthy");
127
+ }
128
+
129
+ await sleep(500);
130
+ }
131
+
132
+ throw new Error("bridge health check did not recover in time");
133
+ }
134
+
135
+ async function probeHealth(url) {
136
+ const client = url.protocol === "https:" ? https : http;
137
+ return await new Promise((resolve) => {
138
+ const req = client.request(
139
+ url,
140
+ { method: "GET", timeout: 3000 },
141
+ (response) => {
142
+ resolve(response.statusCode === 200);
143
+ response.resume();
144
+ }
145
+ );
146
+ req.on("error", () => resolve(false));
147
+ req.on("timeout", () => {
148
+ req.destroy();
149
+ resolve(false);
150
+ });
151
+ req.end();
152
+ });
153
+ }
154
+
53
155
  function commandExists(command) {
54
156
  const checker = process.platform === "win32" ? "where" : "which";
55
157
  const result = spawnSync(checker, [command], { stdio: "ignore" });
@@ -144,6 +246,69 @@ function spawnAndRelay(command, args, options) {
144
246
  });
145
247
  }
146
248
 
249
+ async function spawnDetachedAndWait(command, args, options) {
250
+ const { cwd, env, rootDir, healthTimeoutMs } = options;
251
+ const logPath = bridgeLogFile(rootDir);
252
+ const host = env.BRIDGE_HOST || "127.0.0.1";
253
+ const port = env.BRIDGE_PORT || "8787";
254
+ const healthUrl = new URL(`http://${formatHostForUrl(host)}:${port}/health`);
255
+ const existingPid = readPidFile(rootDir);
256
+
257
+ if (existingPid && isProcessAlive(existingPid)) {
258
+ if (await probeHealth(healthUrl)) {
259
+ console.log(`Bridge already running (pid ${existingPid}).`);
260
+ console.log(`Logs: ${logPath}`);
261
+ console.log(`Bridge is healthy at http://${formatHostForUrl(host)}:${port}`);
262
+ return;
263
+ }
264
+ } else if (existingPid) {
265
+ removePidFile(rootDir);
266
+ }
267
+
268
+ if (await probeHealth(healthUrl)) {
269
+ console.error(
270
+ `error: another bridge is already responding at http://${formatHostForUrl(host)}:${port}. Stop it first with 'clawdex stop'.`
271
+ );
272
+ process.exit(1);
273
+ }
274
+
275
+ const output = fs.openSync(logPath, "a");
276
+ const error = fs.openSync(logPath, "a");
277
+
278
+ const child = spawn(command, args, {
279
+ cwd,
280
+ env,
281
+ detached: true,
282
+ stdio: ["ignore", output, error],
283
+ });
284
+
285
+ child.on("error", (spawnError) => {
286
+ console.error(`error: failed to start ${command}: ${spawnError.message}`);
287
+ removePidFile(rootDir);
288
+ process.exit(1);
289
+ });
290
+
291
+ if (!child.pid) {
292
+ console.error(`error: failed to determine pid for ${command}`);
293
+ process.exit(1);
294
+ }
295
+
296
+ writePidFile(rootDir, child.pid);
297
+ child.unref();
298
+
299
+ console.log(`Bridge starting in background (pid ${child.pid}).`);
300
+ console.log(`Logs: ${logPath}`);
301
+
302
+ try {
303
+ const endpoint = await waitForHealth(env, child.pid, healthTimeoutMs);
304
+ console.log(`Bridge is healthy at http://${formatHostForUrl(endpoint.host)}:${endpoint.port}`);
305
+ } catch (error) {
306
+ removePidFile(rootDir);
307
+ console.error(`error: ${error.message}. Check logs: ${logPath}`);
308
+ process.exit(1);
309
+ }
310
+ }
311
+
147
312
  function buildBridgeFromSource(rootDir, env) {
148
313
  const cargoCmd = "cargo";
149
314
  const args = ["build", "--release", "--locked"];
@@ -163,30 +328,20 @@ function buildBridgeFromSource(rootDir, env) {
163
328
  }
164
329
  }
165
330
 
166
- function start() {
167
- const rootDir = resolveRootDir();
168
- const secureEnvFile = path.join(rootDir, ".env.secure");
169
- if (!fs.existsSync(secureEnvFile)) {
170
- console.error(`error: ${secureEnvFile} not found. Run: npm run secure:setup`);
171
- process.exit(1);
172
- }
173
-
174
- const fileEnv = readEnvFile(secureEnvFile);
175
- const env = { ...fileEnv, ...process.env };
176
- const devMode = process.argv.includes("--dev") || env.BRIDGE_RUN_MODE === "dev";
177
- const forceSourceBuild = env.CLAWDEX_BRIDGE_FORCE_SOURCE_BUILD === "true";
178
-
331
+ function resolveLaunch(rootDir, env, { devMode, forceSourceBuild }) {
179
332
  if (devMode) {
180
333
  if (!commandExists("cargo")) {
181
334
  console.error("error: missing Rust/Cargo toolchain for dev bridge mode.");
182
335
  process.exit(1);
183
336
  }
184
337
 
185
- spawnAndRelay("cargo", ["run"], {
338
+ return {
339
+ command: "cargo",
340
+ args: ["run"],
186
341
  cwd: path.join(rootDir, "services", "rust-bridge"),
187
342
  env,
188
- });
189
- return;
343
+ healthTimeoutMs: DEV_HEALTH_TIMEOUT_MS,
344
+ };
190
345
  }
191
346
 
192
347
  const overrideBinary = env.CLAWDEX_BRIDGE_BINARY ? path.resolve(env.CLAWDEX_BRIDGE_BINARY) : "";
@@ -196,22 +351,37 @@ function start() {
196
351
  process.exit(1);
197
352
  }
198
353
  ensureExecutable(overrideBinary);
199
- spawnAndRelay(overrideBinary, [], { cwd: rootDir, env });
200
- return;
354
+ return {
355
+ command: overrideBinary,
356
+ args: [],
357
+ cwd: rootDir,
358
+ env,
359
+ healthTimeoutMs: DEFAULT_HEALTH_TIMEOUT_MS,
360
+ };
201
361
  }
202
362
 
203
363
  const packagedBinary = packagedBinaryPath(rootDir, resolveRuntimeTarget());
204
364
  if (!forceSourceBuild && packagedBinary && fs.existsSync(packagedBinary)) {
205
365
  ensureExecutable(packagedBinary);
206
- spawnAndRelay(packagedBinary, [], { cwd: rootDir, env });
207
- return;
366
+ return {
367
+ command: packagedBinary,
368
+ args: [],
369
+ cwd: rootDir,
370
+ env,
371
+ healthTimeoutMs: DEFAULT_HEALTH_TIMEOUT_MS,
372
+ };
208
373
  }
209
374
 
210
375
  const builtBinary = builtBinaryPath(rootDir, os.platform());
211
376
  if (isBuiltBinaryFresh(rootDir, builtBinary)) {
212
377
  ensureExecutable(builtBinary);
213
- spawnAndRelay(builtBinary, [], { cwd: rootDir, env });
214
- return;
378
+ return {
379
+ command: builtBinary,
380
+ args: [],
381
+ cwd: rootDir,
382
+ env,
383
+ healthTimeoutMs: DEFAULT_HEALTH_TIMEOUT_MS,
384
+ };
215
385
  }
216
386
 
217
387
  if (!commandExists("cargo")) {
@@ -234,7 +404,44 @@ function start() {
234
404
  }
235
405
 
236
406
  ensureExecutable(builtBinary);
237
- spawnAndRelay(builtBinary, [], { cwd: rootDir, env });
407
+ return {
408
+ command: builtBinary,
409
+ args: [],
410
+ cwd: rootDir,
411
+ env,
412
+ healthTimeoutMs: DEFAULT_HEALTH_TIMEOUT_MS,
413
+ };
414
+ }
415
+
416
+ async function start() {
417
+ const rootDir = resolveRootDir();
418
+ const secureEnvFile = path.join(rootDir, ".env.secure");
419
+ if (!fs.existsSync(secureEnvFile)) {
420
+ console.error(`error: ${secureEnvFile} not found. Run: npm run secure:setup`);
421
+ process.exit(1);
422
+ }
423
+
424
+ const fileEnv = readEnvFile(secureEnvFile);
425
+ const env = { ...fileEnv, ...process.env };
426
+ const devMode = process.argv.includes("--dev") || env.BRIDGE_RUN_MODE === "dev";
427
+ const backgroundMode = process.argv.includes("--background");
428
+ const forceSourceBuild = env.CLAWDEX_BRIDGE_FORCE_SOURCE_BUILD === "true";
429
+ const launch = resolveLaunch(rootDir, env, { devMode, forceSourceBuild });
430
+
431
+ if (backgroundMode) {
432
+ await spawnDetachedAndWait(launch.command, launch.args, {
433
+ cwd: launch.cwd,
434
+ env: launch.env,
435
+ rootDir,
436
+ healthTimeoutMs: launch.healthTimeoutMs,
437
+ });
438
+ return;
439
+ }
440
+
441
+ spawnAndRelay(launch.command, launch.args, { cwd: launch.cwd, env: launch.env });
238
442
  }
239
443
 
240
- start();
444
+ start().catch((error) => {
445
+ console.error(error instanceof Error ? error.message : String(error));
446
+ process.exit(1);
447
+ });
@@ -9,10 +9,68 @@ fi
9
9
 
10
10
  BRIDGE_PID_FILE="$ROOT_DIR/.bridge.pid"
11
11
  EXPO_PID_FILE="$ROOT_DIR/.expo.pid"
12
+ SECURE_ENV_FILE="$ROOT_DIR/.env.secure"
12
13
 
13
14
  list_matching_pids() {
14
15
  local pattern="$1"
15
- pgrep -f "$pattern" 2>/dev/null || true
16
+ ps -ax -o pid= -o command= 2>/dev/null | awk -v pattern="$pattern" '
17
+ $0 ~ pattern { print $1 }
18
+ ' || true
19
+ }
20
+
21
+ stop_pid_file_process() {
22
+ local label="$1"
23
+ local pid_file="$2"
24
+ local pid=""
25
+
26
+ if [[ ! -f "$pid_file" ]]; then
27
+ return 1
28
+ fi
29
+
30
+ pid="$(tr -dc '0-9' <"$pid_file")"
31
+ if [[ -z "$pid" ]]; then
32
+ rm -f "$pid_file"
33
+ return 1
34
+ fi
35
+
36
+ if ! kill -0 "$pid" 2>/dev/null; then
37
+ rm -f "$pid_file"
38
+ return 1
39
+ fi
40
+
41
+ echo "Stopping $label process from pid file: $pid"
42
+ kill -INT "$pid" 2>/dev/null || true
43
+ sleep 1
44
+
45
+ if kill -0 "$pid" 2>/dev/null; then
46
+ kill -TERM "$pid" 2>/dev/null || true
47
+ sleep 1
48
+ fi
49
+
50
+ if kill -0 "$pid" 2>/dev/null; then
51
+ kill -KILL "$pid" 2>/dev/null || true
52
+ echo "Force stopped $label process: $pid"
53
+ else
54
+ echo "$label stopped."
55
+ fi
56
+
57
+ rm -f "$pid_file"
58
+ return 0
59
+ }
60
+
61
+ extract_env_value() {
62
+ local file="$1"
63
+ local key="$2"
64
+ [[ -f "$file" ]] || return 1
65
+
66
+ awk -F= -v key="$key" '
67
+ $1 == key {
68
+ sub(/^[[:space:]]+/, "", $2)
69
+ sub(/[[:space:]]+$/, "", $2)
70
+ print $2
71
+ exit
72
+ }
73
+ ' "$file"
16
74
  }
17
75
 
18
76
  stop_process_group() {
@@ -54,8 +112,19 @@ stop_process_group() {
54
112
  echo "Stopping Clawdex services for project: $ROOT_DIR"
55
113
 
56
114
  stop_process_group "Expo" "$ROOT_DIR/.*/expo start|$ROOT_DIR/node_modules/.bin/expo start"
115
+ stop_pid_file_process "Rust bridge" "$BRIDGE_PID_FILE" || true
57
116
  stop_process_group "Rust bridge" "$ROOT_DIR/services/rust-bridge|codex-rust-bridge|@codex/rust-bridge"
58
117
  stop_process_group "Legacy TS bridge" "$ROOT_DIR/services/mac-bridge|@codex/mac-bridge"
59
118
 
119
+ if [[ -f "$SECURE_ENV_FILE" ]]; then
120
+ BRIDGE_ENABLED_ENGINES="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES" || true)"
121
+ BRIDGE_ACTIVE_ENGINE="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE" || true)"
122
+ BRIDGE_OPENCODE_PORT="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_OPENCODE_PORT" || true)"
123
+ BRIDGE_OPENCODE_PORT="${BRIDGE_OPENCODE_PORT:-4090}"
124
+ if [[ ",$BRIDGE_ENABLED_ENGINES,$BRIDGE_ACTIVE_ENGINE," == *",opencode,"* ]]; then
125
+ stop_process_group "OpenCode server" "opencode serve --hostname .* --port $BRIDGE_OPENCODE_PORT|\\.opencode serve --hostname .* --port $BRIDGE_OPENCODE_PORT"
126
+ fi
127
+ fi
128
+
60
129
  rm -f "$BRIDGE_PID_FILE" "$EXPO_PID_FILE"
61
130
  echo "Done."
@@ -149,12 +149,13 @@ dependencies = [
149
149
 
150
150
  [[package]]
151
151
  name = "codex-rust-bridge"
152
- version = "5.0.5-internal.9"
152
+ version = "5.0.5"
153
153
  dependencies = [
154
154
  "axum",
155
155
  "base64",
156
156
  "chrono",
157
157
  "futures-util",
158
+ "libc",
158
159
  "qr2term",
159
160
  "reqwest",
160
161
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codex-rust-bridge"
3
- version = "5.0.5-internal.9"
3
+ version = "5.0.5"
4
4
  edition = "2021"
5
5
 
6
6
  [dependencies]
@@ -8,6 +8,7 @@ axum = { version = "0.8", features = ["ws", "http1", "tokio"] }
8
8
  base64 = "0.22"
9
9
  chrono = { version = "0.4", features = ["clock", "serde"] }
10
10
  futures-util = "0.3"
11
+ libc = "0.2"
11
12
  serde = { version = "1", features = ["derive"] }
12
13
  serde_json = "1"
13
14
  shlex = "1"
@@ -338,6 +338,15 @@ impl RuntimeBackend {
338
338
  }))
339
339
  }
340
340
 
341
+ async fn shutdown(&self) {
342
+ if let Some(codex) = &self.codex {
343
+ codex.request_shutdown().await;
344
+ }
345
+ if let Some(opencode) = &self.opencode {
346
+ opencode.request_shutdown().await;
347
+ }
348
+ }
349
+
341
350
  fn engine(&self) -> BridgeRuntimeEngine {
342
351
  self.preferred_engine
343
352
  }
@@ -600,6 +609,95 @@ impl RuntimeBackend {
600
609
  }
601
610
  }
602
611
 
612
+ fn configure_managed_child_command(command: &mut Command) {
613
+ command.kill_on_drop(true);
614
+ #[cfg(unix)]
615
+ command.process_group(0);
616
+ }
617
+
618
+ async fn terminate_managed_child(pid: u32, label: &str) {
619
+ #[cfg(unix)]
620
+ {
621
+ terminate_process_group_unix(pid, label).await;
622
+ return;
623
+ }
624
+
625
+ #[cfg(windows)]
626
+ {
627
+ terminate_process_tree_windows(pid, label).await;
628
+ return;
629
+ }
630
+
631
+ #[allow(unreachable_code)]
632
+ let _ = (pid, label);
633
+ }
634
+
635
+ #[cfg(unix)]
636
+ async fn wait_for_shutdown_signal() -> &'static str {
637
+ let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
638
+ .expect("failed to install SIGINT handler");
639
+ let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
640
+ .expect("failed to install SIGTERM handler");
641
+
642
+ tokio::select! {
643
+ _ = sigint.recv() => "SIGINT",
644
+ _ = sigterm.recv() => "SIGTERM",
645
+ }
646
+ }
647
+
648
+ #[cfg(not(unix))]
649
+ async fn wait_for_shutdown_signal() -> &'static str {
650
+ let _ = tokio::signal::ctrl_c().await;
651
+ "Ctrl+C"
652
+ }
653
+
654
+ #[cfg(unix)]
655
+ async fn terminate_process_group_unix(pid: u32, label: &str) {
656
+ let process_group = pid as i32;
657
+ if process_group <= 0 {
658
+ return;
659
+ }
660
+
661
+ let terminate_result = unsafe { libc::killpg(process_group, libc::SIGTERM) };
662
+ if terminate_result != 0 {
663
+ let error = std::io::Error::last_os_error();
664
+ if error.raw_os_error() != Some(libc::ESRCH) {
665
+ eprintln!("failed to terminate {label} process group {process_group}: {error}");
666
+ }
667
+ return;
668
+ }
669
+
670
+ tokio::time::sleep(Duration::from_millis(400)).await;
671
+
672
+ let kill_result = unsafe { libc::killpg(process_group, 0) };
673
+ if kill_result == 0 {
674
+ let force_result = unsafe { libc::killpg(process_group, libc::SIGKILL) };
675
+ if force_result != 0 {
676
+ let error = std::io::Error::last_os_error();
677
+ if error.raw_os_error() != Some(libc::ESRCH) {
678
+ eprintln!("failed to force-kill {label} process group {process_group}: {error}");
679
+ }
680
+ }
681
+ }
682
+ }
683
+
684
+ #[cfg(windows)]
685
+ async fn terminate_process_tree_windows(pid: u32, label: &str) {
686
+ let status = Command::new("taskkill")
687
+ .arg("/PID")
688
+ .arg(pid.to_string())
689
+ .arg("/T")
690
+ .arg("/F")
691
+ .status()
692
+ .await;
693
+
694
+ match status {
695
+ Ok(result) if result.success() => {}
696
+ Ok(result) => eprintln!("failed to terminate {label} process tree {pid}: {result}"),
697
+ Err(error) => eprintln!("failed to terminate {label} process tree {pid}: {error}"),
698
+ }
699
+ }
700
+
603
701
  enum RuntimeBackendRef<'a> {
604
702
  Codex(&'a Arc<AppServerBridge>),
605
703
  Opencode(&'a Arc<OpencodeBackend>),
@@ -773,6 +871,7 @@ impl ClientHub {
773
871
  struct AppServerBridge {
774
872
  engine: BridgeRuntimeEngine,
775
873
  child: Mutex<Child>,
874
+ child_pid: u32,
776
875
  writer: Mutex<ChildStdin>,
777
876
  pending_requests: Mutex<HashMap<u64, PendingRequest>>,
778
877
  internal_waiters: Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>,
@@ -815,15 +914,22 @@ impl AppServerBridge {
815
914
  engine: BridgeRuntimeEngine,
816
915
  hub: Arc<ClientHub>,
817
916
  ) -> Result<Arc<Self>, String> {
818
- let mut child = Command::new(cli_bin)
917
+ let mut command = Command::new(cli_bin);
918
+ command
819
919
  .arg("app-server")
820
920
  .arg("--listen")
821
921
  .arg("stdio://")
822
922
  .stdin(Stdio::piped())
823
923
  .stdout(Stdio::piped())
824
- .stderr(Stdio::piped())
924
+ .stderr(Stdio::piped());
925
+ configure_managed_child_command(&mut command);
926
+
927
+ let mut child = command
825
928
  .spawn()
826
929
  .map_err(|error| format!("failed to start app-server: {error}"))?;
930
+ let child_pid = child
931
+ .id()
932
+ .ok_or_else(|| "app-server pid unavailable".to_string())?;
827
933
 
828
934
  let stdin = child
829
935
  .stdin
@@ -841,6 +947,7 @@ impl AppServerBridge {
841
947
  let bridge = Arc::new(Self {
842
948
  engine,
843
949
  child: Mutex::new(child),
950
+ child_pid,
844
951
  writer: Mutex::new(stdin),
845
952
  pending_requests: Mutex::new(HashMap::new()),
846
953
  internal_waiters: Mutex::new(HashMap::new()),
@@ -861,6 +968,10 @@ impl AppServerBridge {
861
968
  Ok(bridge)
862
969
  }
863
970
 
971
+ async fn request_shutdown(&self) {
972
+ terminate_managed_child(self.child_pid, "app-server").await;
973
+ }
974
+
864
975
  async fn initialize(&self) -> Result<(), String> {
865
976
  let init_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
866
977
  let (tx, rx) = oneshot::channel::<Result<Value, String>>();
@@ -1519,6 +1630,7 @@ struct OpencodePendingUserInputEntry {
1519
1630
 
1520
1631
  struct OpencodeBackend {
1521
1632
  child: Mutex<Child>,
1633
+ child_pid: u32,
1522
1634
  hub: Arc<ClientHub>,
1523
1635
  http: HttpClient,
1524
1636
  base_url: Url,
@@ -1547,6 +1659,7 @@ impl OpencodeBackend {
1547
1659
  .stdin(Stdio::null())
1548
1660
  .stdout(Stdio::piped())
1549
1661
  .stderr(Stdio::piped());
1662
+ configure_managed_child_command(&mut command);
1550
1663
 
1551
1664
  if let Some(password) = config.opencode_server_password.as_deref() {
1552
1665
  command.env("OPENCODE_SERVER_PASSWORD", password);
@@ -1556,6 +1669,9 @@ impl OpencodeBackend {
1556
1669
  let mut child = command
1557
1670
  .spawn()
1558
1671
  .map_err(|error| format!("failed to start opencode serve: {error}"))?;
1672
+ let child_pid = child
1673
+ .id()
1674
+ .ok_or_else(|| "opencode pid unavailable".to_string())?;
1559
1675
 
1560
1676
  let stdout = child
1561
1677
  .stdout
@@ -1574,6 +1690,7 @@ impl OpencodeBackend {
1574
1690
 
1575
1691
  let backend = Arc::new(Self {
1576
1692
  child: Mutex::new(child),
1693
+ child_pid,
1577
1694
  hub,
1578
1695
  http: HttpClient::builder()
1579
1696
  .build()
@@ -1600,6 +1717,10 @@ impl OpencodeBackend {
1600
1717
  Ok(backend)
1601
1718
  }
1602
1719
 
1720
+ async fn request_shutdown(&self) {
1721
+ terminate_managed_child(self.child_pid, "opencode").await;
1722
+ }
1723
+
1603
1724
  fn spawn_stdout_loop(self: &Arc<Self>, stdout: ChildStdout) {
1604
1725
  tokio::spawn(async move {
1605
1726
  let mut lines = BufReader::new(stdout).lines();
@@ -4126,7 +4247,7 @@ async fn main() {
4126
4247
  .route("/rpc", get(ws_handler))
4127
4248
  .route("/health", get(health_handler))
4128
4249
  .route("/local-image", get(local_image_handler))
4129
- .with_state(state);
4250
+ .with_state(state.clone());
4130
4251
 
4131
4252
  let bind_addr = format!("{}:{}", config.host, config.port);
4132
4253
  let listener = match tokio::net::TcpListener::bind(&bind_addr).await {
@@ -4140,7 +4261,18 @@ async fn main() {
4140
4261
  println!("rust-bridge listening on {bind_addr}");
4141
4262
  maybe_print_pairing_qr(&config);
4142
4263
 
4143
- if let Err(error) = axum::serve(listener, app).await {
4264
+ let shutdown_backend = state.backend.clone();
4265
+ let serve_result = axum::serve(listener, app)
4266
+ .with_graceful_shutdown(async move {
4267
+ let signal = wait_for_shutdown_signal().await;
4268
+ eprintln!("shutdown signal received ({signal}), terminating managed backends");
4269
+ shutdown_backend.shutdown().await;
4270
+ })
4271
+ .await;
4272
+
4273
+ state.backend.shutdown().await;
4274
+
4275
+ if let Err(error) = serve_result {
4144
4276
  eprintln!("server error: {error}");
4145
4277
  std::process::exit(1);
4146
4278
  }
@@ -7373,6 +7505,7 @@ mod tests {
7373
7505
  Arc::new(AppServerBridge {
7374
7506
  engine: BridgeRuntimeEngine::Codex,
7375
7507
  child: Mutex::new(child),
7508
+ child_pid: 0,
7376
7509
  writer: Mutex::new(writer),
7377
7510
  pending_requests: Mutex::new(HashMap::new()),
7378
7511
  internal_waiters: Mutex::new(HashMap::new()),
@@ -7401,6 +7534,7 @@ mod tests {
7401
7534
 
7402
7535
  Arc::new(OpencodeBackend {
7403
7536
  child: Mutex::new(child),
7537
+ child_pid: 0,
7404
7538
  hub,
7405
7539
  http: HttpClient::builder().build().expect("build reqwest client"),
7406
7540
  base_url: Url::parse("http://127.0.0.1:4090/").expect("valid opencode base url"),
@@ -8999,6 +9133,7 @@ mod tests {
8999
9133
  let bridge = Arc::new(AppServerBridge {
9000
9134
  engine: BridgeRuntimeEngine::Codex,
9001
9135
  child: Mutex::new(child),
9136
+ child_pid: 0,
9002
9137
  writer: Mutex::new(writer),
9003
9138
  pending_requests: Mutex::new(HashMap::new()),
9004
9139
  internal_waiters: Mutex::new(HashMap::new()),