clawdex-mobile 5.0.5-internal.2 → 5.0.5-internal.6

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
@@ -58,16 +58,16 @@ OpenCode is supported directly from the CLI now.
58
58
  ```bash
59
59
  npm install -g opencode-ai
60
60
  npm install -g clawdex-mobile@latest
61
- clawdex init --engine opencode
61
+ clawdex init --engines codex,opencode
62
62
  ```
63
63
 
64
- That writes `BRIDGE_ACTIVE_ENGINE=opencode` to `.env.secure` and uses OpenCode as the preferred runtime when the bridge starts.
64
+ That writes `BRIDGE_ENABLED_ENGINES=codex,opencode` to `.env.secure`, so the mobile app can control both harnesses from one bridge.
65
65
 
66
66
  Notes:
67
67
 
68
- - `clawdex init` without `--engine` still defaults to Codex.
69
- - If both CLIs are installed, the bridge can surface chats from both engines in the mobile app.
70
- - To switch later, rerun `clawdex init --engine codex` or `clawdex init --engine opencode`.
68
+ - `clawdex init` without flags now lets you multi-select harnesses in the wizard with Space, then Enter to continue.
69
+ - Use `clawdex init --engine codex` or `clawdex init --engine opencode` if you want a single-harness setup.
70
+ - Use `clawdex init --engines codex,opencode` if you want both non-interactively.
71
71
 
72
72
  ## Monorepo Development
73
73
 
@@ -89,7 +89,7 @@ Use `npm run setup:wizard -- --no-start` if you only want to write config.
89
89
 
90
90
  ## Main Commands
91
91
 
92
- - `clawdex init [--engine codex|opencode] [--no-start]`
92
+ - `clawdex init [--engine codex|opencode] [--engines codex,opencode] [--no-start]`
93
93
  - `clawdex stop`
94
94
  - `clawdex upgrade` / `clawdex update`
95
95
  - `clawdex version`
@@ -2,23 +2,25 @@
2
2
 
3
3
  This guide is the detailed companion to the top-level `README.md`.
4
4
 
5
- ## Choosing a Runtime
5
+ ## Choosing Harnesses
6
6
 
7
- The bridge defaults to `codex`.
7
+ The setup wizard now lets you choose which harnesses the phone should control.
8
8
 
9
- Use the setup wizard to make `opencode` the preferred engine:
9
+ If you want both Codex and OpenCode:
10
10
 
11
11
  ```bash
12
- clawdex init --engine opencode
12
+ clawdex init --engines codex,opencode
13
13
  ```
14
14
 
15
15
  From a source checkout, the equivalent command is:
16
16
 
17
17
  ```bash
18
- npm run setup:wizard -- --engine opencode
18
+ npm run setup:wizard -- --engines codex,opencode
19
19
  ```
20
20
 
21
- That writes `BRIDGE_ACTIVE_ENGINE=opencode` into `.env.secure`. This is the default engine preference, not an exclusive mode. When both CLIs are installed, the bridge will expose both Codex and OpenCode in the mobile app. To switch the default back, rerun setup with `--engine codex` or edit `.env.secure` directly.
21
+ That writes `BRIDGE_ENABLED_ENGINES=codex,opencode` into `.env.secure`, so the bridge starts both backends and the mobile app can control both from one UI.
22
+
23
+ If you want only one harness, use `--engine codex` or `--engine opencode`.
22
24
 
23
25
  ## Onboarding Output Cues
24
26
 
@@ -52,7 +54,7 @@ npm run secure:setup
52
54
  To generate OpenCode-first config instead:
53
55
 
54
56
  ```bash
55
- BRIDGE_ACTIVE_ENGINE=opencode npm run secure:setup
57
+ BRIDGE_ENABLED_ENGINES=codex,opencode npm run secure:setup
56
58
  ```
57
59
 
58
60
  Creates/updates:
@@ -69,10 +71,10 @@ npm run secure:bridge
69
71
  If you want a one-off OpenCode launch without rewriting `.env.secure`:
70
72
 
71
73
  ```bash
72
- BRIDGE_ACTIVE_ENGINE=opencode npm run secure:bridge
74
+ BRIDGE_ENABLED_ENGINES=codex,opencode npm run secure:bridge
73
75
  ```
74
76
 
75
- `codex` remains the default. When both CLIs are available, the bridge can start both backends and merge chat lists while still routing each thread by engine.
77
+ When both CLIs are selected, the bridge starts both backends and merges chat lists while still routing each thread by engine.
76
78
 
77
79
  ### 4) Pair from the mobile app
78
80
 
@@ -139,7 +141,8 @@ npm run teardown -- --yes
139
141
  | `BRIDGE_AUTH_TOKEN` | required auth token |
140
142
  | `BRIDGE_ALLOW_QUERY_TOKEN_AUTH` | query-token auth fallback |
141
143
  | `CODEX_CLI_BIN` | codex executable |
142
- | `BRIDGE_ACTIVE_ENGINE` | preferred backend (`codex` default, `opencode` optional) |
144
+ | `BRIDGE_ACTIVE_ENGINE` | internal preferred routing backend used when multiple harnesses are enabled |
145
+ | `BRIDGE_ENABLED_ENGINES` | selected harnesses to expose (`codex`, `opencode`, or both) |
143
146
  | `OPENCODE_CLI_BIN` | opencode executable for dual-engine startup |
144
147
  | `BRIDGE_OPENCODE_HOST` | loopback host for spawned opencode server |
145
148
  | `BRIDGE_OPENCODE_PORT` | loopback port for spawned opencode server |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawdex-mobile",
3
- "version": "5.0.5-internal.2",
3
+ "version": "5.0.5-internal.6",
4
4
  "description": "Private-network mobile bridge and CLI for Codex and OpenCode",
5
5
  "keywords": [
6
6
  "codex",
@@ -10,6 +10,7 @@ SECURE_ENV_FILE="$ROOT_DIR/.env.secure"
10
10
  MOBILE_ENV_FILE="$ROOT_DIR/apps/mobile/.env"
11
11
  MOBILE_ENV_EXAMPLE="$ROOT_DIR/apps/mobile/.env.example"
12
12
  BRIDGE_ACTIVE_ENGINE="${BRIDGE_ACTIVE_ENGINE:-codex}"
13
+ BRIDGE_ENABLED_ENGINES="${BRIDGE_ENABLED_ENGINES:-$BRIDGE_ACTIVE_ENGINE}"
13
14
  OPENCODE_CLI_BIN="${OPENCODE_CLI_BIN:-opencode}"
14
15
 
15
16
  upsert_env_key() {
@@ -237,6 +238,55 @@ case "$BRIDGE_ACTIVE_ENGINE" in
237
238
  ;;
238
239
  esac
239
240
 
241
+ validate_enabled_engines() {
242
+ local raw="$1"
243
+ local normalized=""
244
+ local part=""
245
+ local seen=","
246
+ local -a parts=()
247
+ local -a parsed=()
248
+
249
+ IFS=',' read -r -a parts <<<"$raw"
250
+ for part in "${parts[@]}"; do
251
+ normalized="$(printf '%s' "$part" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
252
+ if [[ -z "$normalized" ]]; then
253
+ continue
254
+ fi
255
+ case "$normalized" in
256
+ codex|opencode)
257
+ ;;
258
+ *)
259
+ return 1
260
+ ;;
261
+ esac
262
+ if [[ "$seen" == *",$normalized,"* ]]; then
263
+ continue
264
+ fi
265
+ parsed+=("$normalized")
266
+ seen="${seen}${normalized},"
267
+ done
268
+
269
+ if (( ${#parsed[@]} == 0 )); then
270
+ return 1
271
+ fi
272
+
273
+ BRIDGE_ENABLED_ENGINES="$(IFS=,; printf '%s' "${parsed[*]}")"
274
+ return 0
275
+ }
276
+
277
+ if ! validate_enabled_engines "$BRIDGE_ENABLED_ENGINES"; then
278
+ echo "error: BRIDGE_ENABLED_ENGINES must contain one or more of 'codex' and 'opencode'." >&2
279
+ exit 1
280
+ fi
281
+
282
+ case ",$BRIDGE_ENABLED_ENGINES," in
283
+ *,"$BRIDGE_ACTIVE_ENGINE",*)
284
+ ;;
285
+ *)
286
+ BRIDGE_ACTIVE_ENGINE="${BRIDGE_ENABLED_ENGINES%%,*}"
287
+ ;;
288
+ esac
289
+
240
290
  if [[ -n "$BRIDGE_HOST" ]]; then
241
291
  HOST_SOURCE="override"
242
292
  else
@@ -269,6 +319,7 @@ BRIDGE_PORT=$BRIDGE_PORT
269
319
  BRIDGE_AUTH_TOKEN=$BRIDGE_TOKEN
270
320
  BRIDGE_ALLOW_QUERY_TOKEN_AUTH=true
271
321
  BRIDGE_ACTIVE_ENGINE=$BRIDGE_ACTIVE_ENGINE
322
+ BRIDGE_ENABLED_ENGINES=$BRIDGE_ENABLED_ENGINES
272
323
  CODEX_CLI_BIN=codex
273
324
  OPENCODE_CLI_BIN=$OPENCODE_CLI_BIN
274
325
  BRIDGE_WORKDIR=$ROOT_DIR
@@ -289,8 +340,7 @@ echo ""
289
340
  echo "Bridge network mode: $BRIDGE_NETWORK_MODE"
290
341
  echo "Bridge host: $BRIDGE_HOST ($HOST_SOURCE)"
291
342
  echo "Bridge port: $BRIDGE_PORT"
292
- echo "Default engine: $BRIDGE_ACTIVE_ENGINE"
293
- echo "Both engines will be exposed in the app when both CLIs are installed."
343
+ echo "Harnesses: $BRIDGE_ENABLED_ENGINES"
294
344
  echo "Token source: $SECURE_ENV_FILE"
295
345
  if has_local_mobile_workspace; then
296
346
  echo "Mobile env updated: $MOBILE_ENV_FILE"
@@ -29,10 +29,14 @@ TAILSCALE_IP=""
29
29
  BRIDGE_HOST=""
30
30
  BRIDGE_PORT=""
31
31
  ACTIVE_ENGINE="${BRIDGE_ACTIVE_ENGINE:-codex}"
32
- ENGINE_PRESET="false"
32
+ declare -a SELECTED_ENGINES=()
33
+ ENGINE_SELECTION_PRESET="false"
33
34
  AUTO_START="true"
34
35
  SECURE_ENV_FILE="$ROOT_DIR/.env.secure"
35
36
  MENU_RESULT=""
37
+ MENU_MULTI_RESULT=""
38
+ MENU_MULTI_PRESELECTED=""
39
+ declare -a MENU_MULTI_RESULT_ITEMS=()
36
40
  SECTION_COUNT=0
37
41
  RAIL_GLYPH="${DIM}│${RESET}"
38
42
  RAIL_BRANCH="${DIM}├─${RESET}"
@@ -47,10 +51,6 @@ ok() { rail_echo "${GREEN}$*${RESET}"; }
47
51
  fail() { printf "%s ${RED}%s${RESET}\n" "$RAIL_GLYPH" "$*" >&2; }
48
52
  SETUP_VERBOSE_INSTALLS="${CLAWDEX_SETUP_VERBOSE:-false}"
49
53
 
50
- if [[ -n "${BRIDGE_ACTIVE_ENGINE:-}" ]]; then
51
- ENGINE_PRESET="true"
52
- fi
53
-
54
54
  run_quiet_command() {
55
55
  local label="$1"
56
56
  shift
@@ -83,7 +83,9 @@ Usage: $(basename "$0") [options]
83
83
  Options:
84
84
  --no-start Configure everything but do not start bridge
85
85
  --engine <codex|opencode>
86
- Set default engine before writing .env.secure
86
+ Select a single harness non-interactively
87
+ --engines <codex,opencode>
88
+ Select one or more harnesses non-interactively
87
89
  -h, --help Show this help
88
90
  EOF
89
91
  }
@@ -110,20 +112,147 @@ format_engine_name() {
110
112
  esac
111
113
  }
112
114
 
113
- secondary_engine_name() {
115
+ parse_engine_list_csv() {
116
+ local raw="$1"
117
+ local normalized=""
118
+ local seen=","
119
+ local part=""
120
+ local -a parts=()
121
+ local -a parsed=()
122
+
123
+ IFS=',' read -r -a parts <<<"$raw"
124
+ for part in "${parts[@]}"; do
125
+ normalized="$(printf '%s' "$part" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
126
+ if [[ -z "$normalized" ]]; then
127
+ continue
128
+ fi
129
+ if ! validate_engine_name "$normalized"; then
130
+ return 1
131
+ fi
132
+ if [[ "$seen" == *",$normalized,"* ]]; then
133
+ continue
134
+ fi
135
+ parsed+=("$normalized")
136
+ seen="${seen}${normalized},"
137
+ done
138
+
139
+ if (( ${#parsed[@]} == 0 )); then
140
+ return 1
141
+ fi
142
+
143
+ SELECTED_ENGINES=("${parsed[@]}")
144
+ return 0
145
+ }
146
+
147
+ parse_existing_engine_list_csv() {
148
+ local raw="$1"
149
+ local normalized=""
150
+ local seen=","
151
+ local part=""
152
+ local -a parts=()
153
+ local -a parsed=()
154
+
155
+ IFS=',' read -r -a parts <<<"$raw"
156
+ for part in "${parts[@]}"; do
157
+ normalized="$(printf '%s' "$part" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
158
+ if [[ -z "$normalized" ]]; then
159
+ continue
160
+ fi
161
+ if ! validate_engine_name "$normalized"; then
162
+ continue
163
+ fi
164
+ if [[ "$seen" == *",$normalized,"* ]]; then
165
+ continue
166
+ fi
167
+ parsed+=("$normalized")
168
+ seen="${seen}${normalized},"
169
+ done
170
+
171
+ if (( ${#parsed[@]} == 0 )); then
172
+ return 1
173
+ fi
174
+
175
+ SELECTED_ENGINES=("${parsed[@]}")
176
+ return 0
177
+ }
178
+
179
+ engine_list_contains() {
180
+ local needle="$1"
181
+ shift
182
+ local value=""
183
+ for value in "$@"; do
184
+ if [[ "$value" == "$needle" ]]; then
185
+ return 0
186
+ fi
187
+ done
188
+ return 1
189
+ }
190
+
191
+ sync_active_engine_from_selection() {
192
+ if (( ${#SELECTED_ENGINES[@]} == 0 )); then
193
+ ACTIVE_ENGINE="codex"
194
+ return
195
+ fi
196
+
197
+ if engine_list_contains "$ACTIVE_ENGINE" "${SELECTED_ENGINES[@]}"; then
198
+ return
199
+ fi
200
+
201
+ ACTIVE_ENGINE="${SELECTED_ENGINES[0]}"
202
+ }
203
+
204
+ format_engine_list() {
205
+ local engine=""
206
+ local result=""
207
+
208
+ for engine in "$@"; do
209
+ if [[ -n "$result" ]]; then
210
+ result+=", "
211
+ fi
212
+ result+="$(format_engine_name "$engine")"
213
+ done
214
+
215
+ printf '%s' "$result"
216
+ }
217
+
218
+ engine_from_menu_label() {
114
219
  case "$1" in
115
- codex)
116
- printf 'opencode'
117
- ;;
118
- opencode)
220
+ "Codex")
119
221
  printf 'codex'
120
222
  ;;
223
+ "OpenCode")
224
+ printf 'opencode'
225
+ ;;
121
226
  *)
122
227
  return 1
123
228
  ;;
124
229
  esac
125
230
  }
126
231
 
232
+ load_existing_engine_selection() {
233
+ local raw=""
234
+ local engine=""
235
+
236
+ raw="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")"
237
+ if [[ -n "$raw" ]] && parse_existing_engine_list_csv "$raw"; then
238
+ engine="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
239
+ if validate_engine_name "$engine"; then
240
+ ACTIVE_ENGINE="$engine"
241
+ else
242
+ ACTIVE_ENGINE="${SELECTED_ENGINES[0]}"
243
+ fi
244
+ sync_active_engine_from_selection
245
+ return 0
246
+ fi
247
+
248
+ engine="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
249
+ if ! validate_engine_name "$engine"; then
250
+ engine="codex"
251
+ fi
252
+ SELECTED_ENGINES=("$engine")
253
+ ACTIVE_ENGINE="$engine"
254
+ }
255
+
127
256
  parse_args() {
128
257
  while (($# > 0)); do
129
258
  case "$1" in
@@ -143,7 +272,23 @@ parse_args() {
143
272
  exit 1
144
273
  fi
145
274
  ACTIVE_ENGINE="$2"
146
- ENGINE_PRESET="true"
275
+ SELECTED_ENGINES=("$2")
276
+ ENGINE_SELECTION_PRESET="true"
277
+ shift 2
278
+ ;;
279
+ --engines)
280
+ if (($# < 2)); then
281
+ echo "error: --engines requires a comma-separated value (for example: codex,opencode)." >&2
282
+ print_usage >&2
283
+ exit 1
284
+ fi
285
+ if ! parse_engine_list_csv "$2"; then
286
+ echo "error: unsupported --engines value '$2'. Use one or more of 'codex' and 'opencode'." >&2
287
+ print_usage >&2
288
+ exit 1
289
+ fi
290
+ ACTIVE_ENGINE="${SELECTED_ENGINES[0]}"
291
+ ENGINE_SELECTION_PRESET="true"
147
292
  shift 2
148
293
  ;;
149
294
  -h|--help)
@@ -499,6 +644,123 @@ menu_select() {
499
644
  printf "\r\033[2K%s %s %s: %s\n" "$rail" "$branch" "$prompt" "$MENU_RESULT"
500
645
  }
501
646
 
647
+ menu_multiselect() {
648
+ local prompt="$1"
649
+ shift
650
+ local -a options=("$@")
651
+ local option_count="${#options[@]}"
652
+ local selected=0
653
+ local lines_to_render=$((option_count + 3))
654
+ local i=0
655
+ local key=""
656
+ local key_rest=""
657
+ local rail="$RAIL_GLYPH"
658
+ local branch="$RAIL_BRANCH"
659
+ local child="$RAIL_CHILD"
660
+ local help_text="${DIM}Space toggles. Enter continues.${RESET}"
661
+ local summary=""
662
+ local mark=""
663
+ local selected_count=0
664
+ local option=""
665
+ local preselected=",${MENU_MULTI_PRESELECTED},"
666
+ local -a checked=()
667
+
668
+ if (( option_count == 0 )); then
669
+ abort_wizard "menu_multiselect requires at least one option."
670
+ fi
671
+
672
+ for option in "${options[@]}"; do
673
+ if [[ "$preselected" == *",$option,"* ]]; then
674
+ checked+=(1)
675
+ else
676
+ checked+=(0)
677
+ fi
678
+ done
679
+
680
+ tput civis >/dev/null 2>&1 || true
681
+
682
+ while true; do
683
+ printf "\r\033[2K%s\n" "$rail"
684
+ printf "\r\033[2K%s %s %s\n" "$rail" "$branch" "$prompt"
685
+ for ((i = 0; i < option_count; i++)); do
686
+ if (( checked[i] == 1 )); then
687
+ mark="[x]"
688
+ else
689
+ mark="[ ]"
690
+ fi
691
+
692
+ if (( i == selected )); then
693
+ printf "\r\033[2K%s %s ${GREEN}%s${RESET} %s\n" "$rail" "$child" "$mark" "${options[$i]}"
694
+ else
695
+ printf "\r\033[2K%s %s ${DIM}%s${RESET} %s\n" "$rail" "$child" "$mark" "${options[$i]}"
696
+ fi
697
+ done
698
+ printf "\r\033[2K%s %s %s\n" "$rail" "$child" "$help_text"
699
+
700
+ IFS= read -rsn1 key || abort_wizard
701
+
702
+ if [[ "$key" == $'\x03' ]]; then
703
+ abort_wizard
704
+ fi
705
+
706
+ if [[ "$key" == $'\x1b' ]]; then
707
+ key_rest=""
708
+ IFS= read -rsn2 key_rest || true
709
+ key+="$key_rest"
710
+ fi
711
+
712
+ case "$key" in
713
+ "")
714
+ selected_count=0
715
+ for ((i = 0; i < option_count; i++)); do
716
+ if (( checked[i] == 1 )); then
717
+ selected_count=$((selected_count + 1))
718
+ fi
719
+ done
720
+ if (( selected_count > 0 )); then
721
+ break
722
+ fi
723
+ help_text="${YELLOW}Select at least one option. Space toggles. Enter continues.${RESET}"
724
+ ;;
725
+ " ")
726
+ checked[selected]=$((1 - checked[selected]))
727
+ help_text="${DIM}Space toggles. Enter continues.${RESET}"
728
+ ;;
729
+ $'\x1b[A'|k|K)
730
+ selected=$(((selected - 1 + option_count) % option_count))
731
+ ;;
732
+ $'\x1b[B'|j|J)
733
+ selected=$(((selected + 1) % option_count))
734
+ ;;
735
+ q|Q)
736
+ abort_wizard
737
+ ;;
738
+ *)
739
+ ;;
740
+ esac
741
+
742
+ printf "\033[%dA" "$lines_to_render"
743
+ done
744
+
745
+ MENU_MULTI_RESULT_ITEMS=()
746
+ for ((i = 0; i < option_count; i++)); do
747
+ if (( checked[i] == 1 )); then
748
+ MENU_MULTI_RESULT_ITEMS+=("${options[$i]}")
749
+ fi
750
+ done
751
+
752
+ MENU_MULTI_RESULT="$(IFS=,; printf '%s' "${MENU_MULTI_RESULT_ITEMS[*]}")"
753
+ summary="$(IFS=', '; printf '%s' "${MENU_MULTI_RESULT_ITEMS[*]}")"
754
+
755
+ tput cnorm >/dev/null 2>&1 || true
756
+ printf "\033[%dA" "$lines_to_render"
757
+ for ((i = 0; i < lines_to_render; i++)); do
758
+ printf "\r\033[2K\n"
759
+ done
760
+ printf "\033[%dA" "$lines_to_render"
761
+ printf "\r\033[2K%s %s %s: %s\n" "$rail" "$branch" "$prompt" "$summary"
762
+ }
763
+
502
764
  confirm_prompt() {
503
765
  local prompt="$1"
504
766
  local default="${2:-N}"
@@ -687,7 +949,7 @@ print_existing_setup_summary() {
687
949
  local port=""
688
950
  local token=""
689
951
  local network_mode=""
690
- local engine=""
952
+ local harnesses=""
691
953
  local source_path=""
692
954
 
693
955
  if [[ ! -f "$SECURE_ENV_FILE" ]]; then
@@ -698,9 +960,14 @@ print_existing_setup_summary() {
698
960
  port="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_PORT")"
699
961
  token="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_AUTH_TOKEN")"
700
962
  network_mode="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_NETWORK_MODE")"
701
- engine="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
702
- if ! validate_engine_name "$engine"; then
703
- engine="codex"
963
+ harnesses="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")"
964
+ if [[ -n "$harnesses" ]] && ! parse_existing_engine_list_csv "$harnesses"; then
965
+ harnesses=""
966
+ elif [[ -n "$harnesses" ]]; then
967
+ harnesses="$(IFS=,; printf '%s' "${SELECTED_ENGINES[*]}")"
968
+ fi
969
+ if [[ -z "$harnesses" ]]; then
970
+ harnesses="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
704
971
  fi
705
972
 
706
973
  if [[ -z "$host" ]] && [[ -z "$port" ]] && [[ -z "$token" ]]; then
@@ -717,8 +984,8 @@ print_existing_setup_summary() {
717
984
  if [[ -n "$network_mode" ]]; then
718
985
  echo "bridge.networkMode: $network_mode"
719
986
  fi
720
- if [[ -n "$engine" ]]; then
721
- echo "bridge.engine: $engine"
987
+ if [[ -n "$harnesses" ]]; then
988
+ echo "bridge.harnesses: $harnesses"
722
989
  fi
723
990
  if [[ -n "$token" ]]; then
724
991
  echo "bridge.token: present"
@@ -803,22 +1070,28 @@ choose_bridge_network_mode() {
803
1070
  }
804
1071
 
805
1072
  choose_runtime_engine() {
806
- info "This selects the default engine."
807
- info "If both CLIs are installed, the bridge will expose both Codex and OpenCode in the app."
808
- menu_select "Default engine" "Codex" "OpenCode"
809
- case "$MENU_RESULT" in
810
- "Codex")
811
- ACTIVE_ENGINE="codex"
812
- info "Codex will be preferred by default."
813
- ;;
814
- "OpenCode")
815
- ACTIVE_ENGINE="opencode"
816
- info "OpenCode will be preferred by default."
817
- ;;
818
- *)
819
- abort_wizard "Unexpected default engine."
820
- ;;
821
- esac
1073
+ local label=""
1074
+ MENU_MULTI_PRESELECTED="Codex"
1075
+ if engine_list_contains "codex" "${SELECTED_ENGINES[@]}"; then
1076
+ MENU_MULTI_PRESELECTED="Codex"
1077
+ else
1078
+ MENU_MULTI_PRESELECTED=""
1079
+ fi
1080
+ if engine_list_contains "opencode" "${SELECTED_ENGINES[@]}"; then
1081
+ if [[ -n "$MENU_MULTI_PRESELECTED" ]]; then
1082
+ MENU_MULTI_PRESELECTED+=","
1083
+ fi
1084
+ MENU_MULTI_PRESELECTED+="OpenCode"
1085
+ fi
1086
+
1087
+ info "Select the harnesses this phone should control."
1088
+ menu_multiselect "Harnesses to control" "Codex" "OpenCode"
1089
+ SELECTED_ENGINES=()
1090
+ for label in "${MENU_MULTI_RESULT_ITEMS[@]}"; do
1091
+ SELECTED_ENGINES+=("$(engine_from_menu_label "$label")")
1092
+ done
1093
+ sync_active_engine_from_selection
1094
+ info "Selected harnesses: $(format_engine_list "${SELECTED_ENGINES[@]}")."
822
1095
  }
823
1096
 
824
1097
  infer_network_mode_from_host() {
@@ -932,55 +1205,21 @@ ensure_opencode_cli() {
932
1205
  fi
933
1206
  }
934
1207
 
935
- ensure_selected_engine_cli() {
936
- case "$ACTIVE_ENGINE" in
937
- codex)
938
- ensure_codex_cli
939
- ;;
940
- opencode)
941
- ensure_opencode_cli
942
- ;;
943
- *)
944
- abort_wizard "Unsupported preferred engine '$ACTIVE_ENGINE'."
945
- ;;
946
- esac
947
- }
948
-
949
- ensure_optional_secondary_engine_cli() {
950
- local secondary_engine=""
951
-
952
- secondary_engine="$(secondary_engine_name "$ACTIVE_ENGINE")" || return 0
953
-
954
- case "$secondary_engine" in
955
- codex)
956
- if command -v codex >/dev/null 2>&1; then
957
- info "Codex is also installed. The bridge will expose both engines."
958
- return 0
959
- fi
960
-
961
- if confirm_prompt "Install Codex too so both engines are available in the app?" "N"; then
1208
+ ensure_selected_engine_clis() {
1209
+ local engine=""
1210
+ for engine in "${SELECTED_ENGINES[@]}"; do
1211
+ case "$engine" in
1212
+ codex)
962
1213
  ensure_codex_cli
963
- ok "Codex added. The bridge will expose both engines."
964
- else
965
- info "Continuing without Codex. Install it later if you want both engines."
966
- fi
967
- ;;
968
- opencode)
969
- if command -v opencode >/dev/null 2>&1; then
970
- info "OpenCode is also installed. The bridge will expose both engines."
971
- return 0
972
- fi
973
-
974
- if confirm_prompt "Install OpenCode too so both engines are available in the app?" "N"; then
1214
+ ;;
1215
+ opencode)
975
1216
  ensure_opencode_cli
976
- ok "OpenCode added. The bridge will expose both engines."
977
- else
978
- info "Continuing without OpenCode. Install it later if you want both engines."
979
- fi
980
- ;;
981
- *)
982
- ;;
983
- esac
1217
+ ;;
1218
+ *)
1219
+ abort_wizard "Unsupported harness '$engine'."
1220
+ ;;
1221
+ esac
1222
+ done
984
1223
  }
985
1224
 
986
1225
  ensure_tailscale_cli() {
@@ -1362,29 +1601,24 @@ if [[ "$CONFIG_ACTION" == "reset" ]]; then
1362
1601
  ok "Previous secure config removed: $SECURE_ENV_FILE"
1363
1602
  fi
1364
1603
 
1365
- section "Default engine"
1366
- EXISTING_ACTIVE_ENGINE="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
1367
- if ! validate_engine_name "$EXISTING_ACTIVE_ENGINE"; then
1368
- EXISTING_ACTIVE_ENGINE="codex"
1369
- fi
1604
+ load_existing_engine_selection
1370
1605
  if [[ "$CONFIG_ACTION" == "keep" ]]; then
1371
- if [[ "$ENGINE_PRESET" == "false" ]]; then
1372
- ACTIVE_ENGINE="$EXISTING_ACTIVE_ENGINE"
1373
- info "Keeping existing default engine: $(format_engine_name "$ACTIVE_ENGINE")."
1606
+ if [[ "$ENGINE_SELECTION_PRESET" == "false" ]]; then
1607
+ info "Keeping existing harnesses: $(format_engine_list "${SELECTED_ENGINES[@]}")."
1374
1608
  else
1375
- info "Default engine preset via flag: $(format_engine_name "$ACTIVE_ENGINE")."
1609
+ info "Harness selection preset via flag: $(format_engine_list "${SELECTED_ENGINES[@]}")."
1376
1610
  fi
1377
1611
  else
1378
- if [[ "$ENGINE_PRESET" == "false" ]]; then
1612
+ section "Harnesses"
1613
+ if [[ "$ENGINE_SELECTION_PRESET" == "false" ]]; then
1379
1614
  choose_runtime_engine
1380
1615
  else
1381
- info "Default engine preset via flag: $(format_engine_name "$ACTIVE_ENGINE")."
1616
+ info "Harness selection preset via flag: $(format_engine_list "${SELECTED_ENGINES[@]}")."
1382
1617
  fi
1383
1618
  fi
1384
1619
 
1385
1620
  section "Runtime dependency"
1386
- ensure_selected_engine_cli
1387
- ensure_optional_secondary_engine_cli
1621
+ ensure_selected_engine_clis
1388
1622
 
1389
1623
  if [[ "$CONFIG_ACTION" != "keep" ]]; then
1390
1624
  section "Bridge network mode"
@@ -1409,7 +1643,7 @@ if [[ "$CONFIG_ACTION" != "keep" ]]; then
1409
1643
  esac
1410
1644
 
1411
1645
  section "Write secure config"
1412
- BRIDGE_NETWORK_MODE="$NETWORK_MODE" BRIDGE_HOST_OVERRIDE="$BRIDGE_HOST" BRIDGE_ACTIVE_ENGINE="$ACTIVE_ENGINE" "$SCRIPT_DIR/setup-secure-dev.sh"
1646
+ BRIDGE_NETWORK_MODE="$NETWORK_MODE" BRIDGE_HOST_OVERRIDE="$BRIDGE_HOST" BRIDGE_ACTIVE_ENGINE="$ACTIVE_ENGINE" BRIDGE_ENABLED_ENGINES="$(IFS=,; printf '%s' "${SELECTED_ENGINES[*]}")" "$SCRIPT_DIR/setup-secure-dev.sh"
1413
1647
  else
1414
1648
  ok "Keeping existing secure config."
1415
1649
  NETWORK_MODE="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_NETWORK_MODE")"
@@ -1434,10 +1668,10 @@ else
1434
1668
  fi
1435
1669
 
1436
1670
  section "Write secure config"
1437
- BRIDGE_NETWORK_MODE="$NETWORK_MODE" BRIDGE_HOST_OVERRIDE="$BRIDGE_HOST" BRIDGE_ACTIVE_ENGINE="$ACTIVE_ENGINE" "$SCRIPT_DIR/setup-secure-dev.sh"
1438
- elif [[ "$ACTIVE_ENGINE" != "$EXISTING_ACTIVE_ENGINE" ]]; then
1671
+ BRIDGE_NETWORK_MODE="$NETWORK_MODE" BRIDGE_HOST_OVERRIDE="$BRIDGE_HOST" BRIDGE_ACTIVE_ENGINE="$ACTIVE_ENGINE" BRIDGE_ENABLED_ENGINES="$(IFS=,; printf '%s' "${SELECTED_ENGINES[*]}")" "$SCRIPT_DIR/setup-secure-dev.sh"
1672
+ elif [[ "$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")" != "$(IFS=,; printf '%s' "${SELECTED_ENGINES[*]}")" ]]; then
1439
1673
  section "Write secure config"
1440
- BRIDGE_NETWORK_MODE="$NETWORK_MODE" BRIDGE_HOST_OVERRIDE="$BRIDGE_HOST" BRIDGE_ACTIVE_ENGINE="$ACTIVE_ENGINE" "$SCRIPT_DIR/setup-secure-dev.sh"
1674
+ BRIDGE_NETWORK_MODE="$NETWORK_MODE" BRIDGE_HOST_OVERRIDE="$BRIDGE_HOST" BRIDGE_ACTIVE_ENGINE="$ACTIVE_ENGINE" BRIDGE_ENABLED_ENGINES="$(IFS=,; printf '%s' "${SELECTED_ENGINES[*]}")" "$SCRIPT_DIR/setup-secure-dev.sh"
1441
1675
  fi
1442
1676
  fi
1443
1677
 
@@ -1467,8 +1701,7 @@ BRIDGE_PORT="${BRIDGE_PORT:-8787}"
1467
1701
  section "Summary"
1468
1702
  rail_echo "Bridge mode: $NETWORK_MODE"
1469
1703
  rail_echo "Bridge endpoint: http://$BRIDGE_HOST:$BRIDGE_PORT"
1470
- rail_echo "Default engine: $(format_engine_name "$ACTIVE_ENGINE")"
1471
- rail_echo "${DIM}Both engines appear in the app when both CLIs are installed.${RESET}"
1704
+ rail_echo "Harnesses: $(format_engine_list "${SELECTED_ENGINES[@]}")"
1472
1705
  rail_echo "Secure env: $SECURE_ENV_FILE"
1473
1706
  if [[ "$FLOW" == "quickstart" ]]; then
1474
1707
  rail_echo "${DIM}Tip: re-run with Manual mode for full control at each step.${RESET}"
@@ -149,7 +149,7 @@ dependencies = [
149
149
 
150
150
  [[package]]
151
151
  name = "codex-rust-bridge"
152
- version = "5.0.4"
152
+ version = "5.0.5-internal.6"
153
153
  dependencies = [
154
154
  "axum",
155
155
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codex-rust-bridge"
3
- version = "5.0.4"
3
+ version = "5.0.5-internal.6"
4
4
  edition = "2021"
5
5
 
6
6
  [dependencies]
@@ -75,6 +75,7 @@ struct BridgeConfig {
75
75
  cli_bin: String,
76
76
  opencode_cli_bin: String,
77
77
  active_engine: BridgeRuntimeEngine,
78
+ enabled_engines: Vec<BridgeRuntimeEngine>,
78
79
  opencode_host: String,
79
80
  opencode_port: u16,
80
81
  opencode_server_username: String,
@@ -105,11 +106,18 @@ impl BridgeConfig {
105
106
  let cli_bin = env::var("CODEX_CLI_BIN").unwrap_or_else(|_| "codex".to_string());
106
107
  let opencode_cli_bin =
107
108
  env::var("OPENCODE_CLI_BIN").unwrap_or_else(|_| "opencode".to_string());
108
- let active_engine = match env::var("BRIDGE_ACTIVE_ENGINE") {
109
+ let requested_active_engine = match env::var("BRIDGE_ACTIVE_ENGINE") {
109
110
  Ok(raw) => parse_bridge_runtime_engine(raw.trim())
110
111
  .ok_or_else(|| format!("unsupported BRIDGE_ACTIVE_ENGINE value: {raw}"))?,
111
112
  Err(_) => BridgeRuntimeEngine::Codex,
112
113
  };
114
+ let enabled_engines = parse_enabled_bridge_engines_env()?
115
+ .unwrap_or_else(|| legacy_default_enabled_engines(requested_active_engine));
116
+ let active_engine = if enabled_engines.contains(&requested_active_engine) {
117
+ requested_active_engine
118
+ } else {
119
+ enabled_engines[0]
120
+ };
113
121
  let opencode_host =
114
122
  env::var("BRIDGE_OPENCODE_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
115
123
  let opencode_port = env::var("BRIDGE_OPENCODE_PORT")
@@ -159,6 +167,7 @@ impl BridgeConfig {
159
167
  cli_bin,
160
168
  opencode_cli_bin,
161
169
  active_engine,
170
+ enabled_engines,
162
171
  opencode_host,
163
172
  opencode_port,
164
173
  opencode_server_username,
@@ -224,7 +233,7 @@ struct AppState {
224
233
  }
225
234
 
226
235
  #[allow(dead_code)]
227
- #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
236
+ #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Hash)]
228
237
  #[serde(rename_all = "lowercase")]
229
238
  enum BridgeRuntimeEngine {
230
239
  Codex,
@@ -267,45 +276,57 @@ struct RuntimeBackend {
267
276
  impl RuntimeBackend {
268
277
  async fn start(config: &Arc<BridgeConfig>, hub: Arc<ClientHub>) -> Result<Arc<Self>, String> {
269
278
  let preferred_engine = config.active_engine;
279
+ let codex_enabled = config.enabled_engines.contains(&BridgeRuntimeEngine::Codex);
280
+ let opencode_enabled = config
281
+ .enabled_engines
282
+ .contains(&BridgeRuntimeEngine::Opencode);
270
283
  let mut codex = None;
271
284
  let mut opencode = None;
272
285
 
273
286
  match preferred_engine {
274
287
  BridgeRuntimeEngine::Codex => {
275
- let app_server = AppServerBridge::start(
276
- &config.cli_bin,
277
- BridgeRuntimeEngine::Codex,
278
- hub.clone(),
279
- )
280
- .await?;
281
- spawn_rollout_live_sync(hub.clone());
282
- codex = Some(app_server);
283
-
284
- match OpencodeBackend::start(config, hub).await {
285
- Ok(backend) => opencode = Some(backend),
286
- Err(error) => eprintln!(
287
- "opencode backend unavailable; continuing with codex only: {error}"
288
- ),
288
+ if codex_enabled {
289
+ let app_server = AppServerBridge::start(
290
+ &config.cli_bin,
291
+ BridgeRuntimeEngine::Codex,
292
+ hub.clone(),
293
+ )
294
+ .await?;
295
+ spawn_rollout_live_sync(hub.clone());
296
+ codex = Some(app_server);
297
+ }
298
+
299
+ if opencode_enabled {
300
+ match OpencodeBackend::start(config, hub).await {
301
+ Ok(backend) => opencode = Some(backend),
302
+ Err(error) => eprintln!(
303
+ "opencode backend unavailable; continuing with selected harnesses only: {error}"
304
+ ),
305
+ }
289
306
  }
290
307
  }
291
308
  BridgeRuntimeEngine::Opencode => {
292
- let backend = OpencodeBackend::start(config, hub.clone()).await?;
293
- opencode = Some(backend);
309
+ if opencode_enabled {
310
+ let backend = OpencodeBackend::start(config, hub.clone()).await?;
311
+ opencode = Some(backend);
312
+ }
294
313
 
295
- match AppServerBridge::start(
296
- &config.cli_bin,
297
- BridgeRuntimeEngine::Codex,
298
- hub.clone(),
299
- )
300
- .await
301
- {
302
- Ok(app_server) => {
303
- spawn_rollout_live_sync(hub);
304
- codex = Some(app_server);
314
+ if codex_enabled {
315
+ match AppServerBridge::start(
316
+ &config.cli_bin,
317
+ BridgeRuntimeEngine::Codex,
318
+ hub.clone(),
319
+ )
320
+ .await
321
+ {
322
+ Ok(app_server) => {
323
+ spawn_rollout_live_sync(hub);
324
+ codex = Some(app_server);
325
+ }
326
+ Err(error) => eprintln!(
327
+ "codex backend unavailable; continuing with selected harnesses only: {error}"
328
+ ),
305
329
  }
306
- Err(error) => eprintln!(
307
- "codex backend unavailable; continuing with opencode only: {error}"
308
- ),
309
330
  }
310
331
  }
311
332
  }
@@ -5348,6 +5369,53 @@ fn parse_csv_env(name: &str, fallback: &[&str]) -> HashSet<String> {
5348
5369
  }
5349
5370
  }
5350
5371
 
5372
+ fn parse_enabled_bridge_engines_csv(raw: &str) -> Result<Vec<BridgeRuntimeEngine>, String> {
5373
+ let mut parsed = Vec::new();
5374
+ let mut seen = HashSet::new();
5375
+ for entry in raw.split(',') {
5376
+ let normalized = entry.trim().to_ascii_lowercase();
5377
+ if normalized.is_empty() {
5378
+ continue;
5379
+ }
5380
+ let Some(engine) = parse_bridge_runtime_engine(&normalized) else {
5381
+ continue;
5382
+ };
5383
+ if seen.insert(engine) {
5384
+ parsed.push(engine);
5385
+ }
5386
+ }
5387
+
5388
+ if parsed.is_empty() {
5389
+ return Err(
5390
+ "BRIDGE_ENABLED_ENGINES must include one or more of: codex, opencode".to_string(),
5391
+ );
5392
+ }
5393
+
5394
+ Ok(parsed)
5395
+ }
5396
+
5397
+ fn parse_enabled_bridge_engines_env() -> Result<Option<Vec<BridgeRuntimeEngine>>, String> {
5398
+ let raw = match env::var("BRIDGE_ENABLED_ENGINES") {
5399
+ Ok(raw) => raw,
5400
+ Err(_) => return Ok(None),
5401
+ };
5402
+
5403
+ Ok(Some(parse_enabled_bridge_engines_csv(&raw)?))
5404
+ }
5405
+
5406
+ fn legacy_default_enabled_engines(
5407
+ requested_active_engine: BridgeRuntimeEngine,
5408
+ ) -> Vec<BridgeRuntimeEngine> {
5409
+ match requested_active_engine {
5410
+ BridgeRuntimeEngine::Codex => {
5411
+ vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode]
5412
+ }
5413
+ BridgeRuntimeEngine::Opencode => {
5414
+ vec![BridgeRuntimeEngine::Opencode, BridgeRuntimeEngine::Codex]
5415
+ }
5416
+ }
5417
+ }
5418
+
5351
5419
  impl BridgeRuntimeEngine {
5352
5420
  fn as_str(self) -> &'static str {
5353
5421
  match self {
@@ -7399,6 +7467,7 @@ mod tests {
7399
7467
  cli_bin: "cat".to_string(),
7400
7468
  opencode_cli_bin: "opencode".to_string(),
7401
7469
  active_engine: BridgeRuntimeEngine::Codex,
7470
+ enabled_engines: vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode],
7402
7471
  opencode_host: "127.0.0.1".to_string(),
7403
7472
  opencode_port: 4090,
7404
7473
  opencode_server_username: "opencode".to_string(),
@@ -7969,6 +8038,25 @@ mod tests {
7969
8038
  shutdown_test_backend(&state.backend).await;
7970
8039
  }
7971
8040
 
8041
+ #[test]
8042
+ fn parse_enabled_bridge_engines_csv_preserves_order_and_removes_duplicates() {
8043
+ let parsed =
8044
+ parse_enabled_bridge_engines_csv("opencode,codex,opencode").expect("engine csv");
8045
+ assert_eq!(
8046
+ parsed,
8047
+ vec![BridgeRuntimeEngine::Opencode, BridgeRuntimeEngine::Codex]
8048
+ );
8049
+ }
8050
+
8051
+ #[test]
8052
+ fn parse_enabled_bridge_engines_csv_ignores_unknown_entries() {
8053
+ let parsed = parse_enabled_bridge_engines_csv("codex,t3code,opencode").expect("engine csv");
8054
+ assert_eq!(
8055
+ parsed,
8056
+ vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode]
8057
+ );
8058
+ }
8059
+
7972
8060
  #[tokio::test]
7973
8061
  async fn bridge_capabilities_reflect_single_engine_state() {
7974
8062
  let hub = Arc::new(ClientHub::new());
@@ -8796,6 +8884,7 @@ mod tests {
8796
8884
  cli_bin: "codex".to_string(),
8797
8885
  opencode_cli_bin: "opencode".to_string(),
8798
8886
  active_engine: BridgeRuntimeEngine::Codex,
8887
+ enabled_engines: vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode],
8799
8888
  opencode_host: "127.0.0.1".to_string(),
8800
8889
  opencode_port: 4090,
8801
8890
  opencode_server_username: "opencode".to_string(),