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

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.4",
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,115 @@ 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
+ engine_list_contains() {
148
+ local needle="$1"
149
+ shift
150
+ local value=""
151
+ for value in "$@"; do
152
+ if [[ "$value" == "$needle" ]]; then
153
+ return 0
154
+ fi
155
+ done
156
+ return 1
157
+ }
158
+
159
+ sync_active_engine_from_selection() {
160
+ if (( ${#SELECTED_ENGINES[@]} == 0 )); then
161
+ ACTIVE_ENGINE="codex"
162
+ return
163
+ fi
164
+
165
+ if engine_list_contains "$ACTIVE_ENGINE" "${SELECTED_ENGINES[@]}"; then
166
+ return
167
+ fi
168
+
169
+ ACTIVE_ENGINE="${SELECTED_ENGINES[0]}"
170
+ }
171
+
172
+ format_engine_list() {
173
+ local engine=""
174
+ local result=""
175
+
176
+ for engine in "$@"; do
177
+ if [[ -n "$result" ]]; then
178
+ result+=", "
179
+ fi
180
+ result+="$(format_engine_name "$engine")"
181
+ done
182
+
183
+ printf '%s' "$result"
184
+ }
185
+
186
+ engine_from_menu_label() {
114
187
  case "$1" in
115
- codex)
116
- printf 'opencode'
117
- ;;
118
- opencode)
188
+ "Codex")
119
189
  printf 'codex'
120
190
  ;;
191
+ "OpenCode")
192
+ printf 'opencode'
193
+ ;;
121
194
  *)
122
195
  return 1
123
196
  ;;
124
197
  esac
125
198
  }
126
199
 
200
+ load_existing_engine_selection() {
201
+ local raw=""
202
+ local engine=""
203
+
204
+ raw="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")"
205
+ if [[ -n "$raw" ]] && parse_engine_list_csv "$raw"; then
206
+ engine="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
207
+ if validate_engine_name "$engine"; then
208
+ ACTIVE_ENGINE="$engine"
209
+ else
210
+ ACTIVE_ENGINE="${SELECTED_ENGINES[0]}"
211
+ fi
212
+ sync_active_engine_from_selection
213
+ return 0
214
+ fi
215
+
216
+ engine="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
217
+ if ! validate_engine_name "$engine"; then
218
+ engine="codex"
219
+ fi
220
+ SELECTED_ENGINES=("$engine")
221
+ ACTIVE_ENGINE="$engine"
222
+ }
223
+
127
224
  parse_args() {
128
225
  while (($# > 0)); do
129
226
  case "$1" in
@@ -143,7 +240,23 @@ parse_args() {
143
240
  exit 1
144
241
  fi
145
242
  ACTIVE_ENGINE="$2"
146
- ENGINE_PRESET="true"
243
+ SELECTED_ENGINES=("$2")
244
+ ENGINE_SELECTION_PRESET="true"
245
+ shift 2
246
+ ;;
247
+ --engines)
248
+ if (($# < 2)); then
249
+ echo "error: --engines requires a comma-separated value (for example: codex,opencode)." >&2
250
+ print_usage >&2
251
+ exit 1
252
+ fi
253
+ if ! parse_engine_list_csv "$2"; then
254
+ echo "error: unsupported --engines value '$2'. Use one or more of 'codex' and 'opencode'." >&2
255
+ print_usage >&2
256
+ exit 1
257
+ fi
258
+ ACTIVE_ENGINE="${SELECTED_ENGINES[0]}"
259
+ ENGINE_SELECTION_PRESET="true"
147
260
  shift 2
148
261
  ;;
149
262
  -h|--help)
@@ -499,6 +612,123 @@ menu_select() {
499
612
  printf "\r\033[2K%s %s %s: %s\n" "$rail" "$branch" "$prompt" "$MENU_RESULT"
500
613
  }
501
614
 
615
+ menu_multiselect() {
616
+ local prompt="$1"
617
+ shift
618
+ local -a options=("$@")
619
+ local option_count="${#options[@]}"
620
+ local selected=0
621
+ local lines_to_render=$((option_count + 3))
622
+ local i=0
623
+ local key=""
624
+ local key_rest=""
625
+ local rail="$RAIL_GLYPH"
626
+ local branch="$RAIL_BRANCH"
627
+ local child="$RAIL_CHILD"
628
+ local help_text="${DIM}Space toggles. Enter continues.${RESET}"
629
+ local summary=""
630
+ local mark=""
631
+ local selected_count=0
632
+ local option=""
633
+ local preselected=",${MENU_MULTI_PRESELECTED},"
634
+ local -a checked=()
635
+
636
+ if (( option_count == 0 )); then
637
+ abort_wizard "menu_multiselect requires at least one option."
638
+ fi
639
+
640
+ for option in "${options[@]}"; do
641
+ if [[ "$preselected" == *",$option,"* ]]; then
642
+ checked+=(1)
643
+ else
644
+ checked+=(0)
645
+ fi
646
+ done
647
+
648
+ tput civis >/dev/null 2>&1 || true
649
+
650
+ while true; do
651
+ printf "\r\033[2K%s\n" "$rail"
652
+ printf "\r\033[2K%s %s %s\n" "$rail" "$branch" "$prompt"
653
+ for ((i = 0; i < option_count; i++)); do
654
+ if (( checked[i] == 1 )); then
655
+ mark="[x]"
656
+ else
657
+ mark="[ ]"
658
+ fi
659
+
660
+ if (( i == selected )); then
661
+ printf "\r\033[2K%s %s ${GREEN}%s${RESET} %s\n" "$rail" "$child" "$mark" "${options[$i]}"
662
+ else
663
+ printf "\r\033[2K%s %s ${DIM}%s${RESET} %s\n" "$rail" "$child" "$mark" "${options[$i]}"
664
+ fi
665
+ done
666
+ printf "\r\033[2K%s %s %s\n" "$rail" "$child" "$help_text"
667
+
668
+ IFS= read -rsn1 key || abort_wizard
669
+
670
+ if [[ "$key" == $'\x03' ]]; then
671
+ abort_wizard
672
+ fi
673
+
674
+ if [[ "$key" == $'\x1b' ]]; then
675
+ key_rest=""
676
+ IFS= read -rsn2 key_rest || true
677
+ key+="$key_rest"
678
+ fi
679
+
680
+ case "$key" in
681
+ "")
682
+ selected_count=0
683
+ for ((i = 0; i < option_count; i++)); do
684
+ if (( checked[i] == 1 )); then
685
+ selected_count=$((selected_count + 1))
686
+ fi
687
+ done
688
+ if (( selected_count > 0 )); then
689
+ break
690
+ fi
691
+ help_text="${YELLOW}Select at least one option. Space toggles. Enter continues.${RESET}"
692
+ ;;
693
+ " ")
694
+ checked[selected]=$((1 - checked[selected]))
695
+ help_text="${DIM}Space toggles. Enter continues.${RESET}"
696
+ ;;
697
+ $'\x1b[A'|k|K)
698
+ selected=$(((selected - 1 + option_count) % option_count))
699
+ ;;
700
+ $'\x1b[B'|j|J)
701
+ selected=$(((selected + 1) % option_count))
702
+ ;;
703
+ q|Q)
704
+ abort_wizard
705
+ ;;
706
+ *)
707
+ ;;
708
+ esac
709
+
710
+ printf "\033[%dA" "$lines_to_render"
711
+ done
712
+
713
+ MENU_MULTI_RESULT_ITEMS=()
714
+ for ((i = 0; i < option_count; i++)); do
715
+ if (( checked[i] == 1 )); then
716
+ MENU_MULTI_RESULT_ITEMS+=("${options[$i]}")
717
+ fi
718
+ done
719
+
720
+ MENU_MULTI_RESULT="$(IFS=,; printf '%s' "${MENU_MULTI_RESULT_ITEMS[*]}")"
721
+ summary="$(IFS=', '; printf '%s' "${MENU_MULTI_RESULT_ITEMS[*]}")"
722
+
723
+ tput cnorm >/dev/null 2>&1 || true
724
+ printf "\033[%dA" "$lines_to_render"
725
+ for ((i = 0; i < lines_to_render; i++)); do
726
+ printf "\r\033[2K\n"
727
+ done
728
+ printf "\033[%dA" "$lines_to_render"
729
+ printf "\r\033[2K%s %s %s: %s\n" "$rail" "$branch" "$prompt" "$summary"
730
+ }
731
+
502
732
  confirm_prompt() {
503
733
  local prompt="$1"
504
734
  local default="${2:-N}"
@@ -687,7 +917,7 @@ print_existing_setup_summary() {
687
917
  local port=""
688
918
  local token=""
689
919
  local network_mode=""
690
- local engine=""
920
+ local harnesses=""
691
921
  local source_path=""
692
922
 
693
923
  if [[ ! -f "$SECURE_ENV_FILE" ]]; then
@@ -698,9 +928,9 @@ print_existing_setup_summary() {
698
928
  port="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_PORT")"
699
929
  token="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_AUTH_TOKEN")"
700
930
  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"
931
+ harnesses="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")"
932
+ if [[ -z "$harnesses" ]]; then
933
+ harnesses="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ACTIVE_ENGINE")"
704
934
  fi
705
935
 
706
936
  if [[ -z "$host" ]] && [[ -z "$port" ]] && [[ -z "$token" ]]; then
@@ -717,8 +947,8 @@ print_existing_setup_summary() {
717
947
  if [[ -n "$network_mode" ]]; then
718
948
  echo "bridge.networkMode: $network_mode"
719
949
  fi
720
- if [[ -n "$engine" ]]; then
721
- echo "bridge.engine: $engine"
950
+ if [[ -n "$harnesses" ]]; then
951
+ echo "bridge.harnesses: $harnesses"
722
952
  fi
723
953
  if [[ -n "$token" ]]; then
724
954
  echo "bridge.token: present"
@@ -803,22 +1033,28 @@ choose_bridge_network_mode() {
803
1033
  }
804
1034
 
805
1035
  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
1036
+ local label=""
1037
+ MENU_MULTI_PRESELECTED="Codex"
1038
+ if engine_list_contains "codex" "${SELECTED_ENGINES[@]}"; then
1039
+ MENU_MULTI_PRESELECTED="Codex"
1040
+ else
1041
+ MENU_MULTI_PRESELECTED=""
1042
+ fi
1043
+ if engine_list_contains "opencode" "${SELECTED_ENGINES[@]}"; then
1044
+ if [[ -n "$MENU_MULTI_PRESELECTED" ]]; then
1045
+ MENU_MULTI_PRESELECTED+=","
1046
+ fi
1047
+ MENU_MULTI_PRESELECTED+="OpenCode"
1048
+ fi
1049
+
1050
+ info "Select the harnesses this phone should control."
1051
+ menu_multiselect "Harnesses to control" "Codex" "OpenCode"
1052
+ SELECTED_ENGINES=()
1053
+ for label in "${MENU_MULTI_RESULT_ITEMS[@]}"; do
1054
+ SELECTED_ENGINES+=("$(engine_from_menu_label "$label")")
1055
+ done
1056
+ sync_active_engine_from_selection
1057
+ info "Selected harnesses: $(format_engine_list "${SELECTED_ENGINES[@]}")."
822
1058
  }
823
1059
 
824
1060
  infer_network_mode_from_host() {
@@ -932,55 +1168,21 @@ ensure_opencode_cli() {
932
1168
  fi
933
1169
  }
934
1170
 
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
1171
+ ensure_selected_engine_clis() {
1172
+ local engine=""
1173
+ for engine in "${SELECTED_ENGINES[@]}"; do
1174
+ case "$engine" in
1175
+ codex)
962
1176
  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
1177
+ ;;
1178
+ opencode)
975
1179
  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
1180
+ ;;
1181
+ *)
1182
+ abort_wizard "Unsupported harness '$engine'."
1183
+ ;;
1184
+ esac
1185
+ done
984
1186
  }
985
1187
 
986
1188
  ensure_tailscale_cli() {
@@ -1362,29 +1564,24 @@ if [[ "$CONFIG_ACTION" == "reset" ]]; then
1362
1564
  ok "Previous secure config removed: $SECURE_ENV_FILE"
1363
1565
  fi
1364
1566
 
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
1567
+ load_existing_engine_selection
1370
1568
  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")."
1569
+ if [[ "$ENGINE_SELECTION_PRESET" == "false" ]]; then
1570
+ info "Keeping existing harnesses: $(format_engine_list "${SELECTED_ENGINES[@]}")."
1374
1571
  else
1375
- info "Default engine preset via flag: $(format_engine_name "$ACTIVE_ENGINE")."
1572
+ info "Harness selection preset via flag: $(format_engine_list "${SELECTED_ENGINES[@]}")."
1376
1573
  fi
1377
1574
  else
1378
- if [[ "$ENGINE_PRESET" == "false" ]]; then
1575
+ section "Harnesses"
1576
+ if [[ "$ENGINE_SELECTION_PRESET" == "false" ]]; then
1379
1577
  choose_runtime_engine
1380
1578
  else
1381
- info "Default engine preset via flag: $(format_engine_name "$ACTIVE_ENGINE")."
1579
+ info "Harness selection preset via flag: $(format_engine_list "${SELECTED_ENGINES[@]}")."
1382
1580
  fi
1383
1581
  fi
1384
1582
 
1385
1583
  section "Runtime dependency"
1386
- ensure_selected_engine_cli
1387
- ensure_optional_secondary_engine_cli
1584
+ ensure_selected_engine_clis
1388
1585
 
1389
1586
  if [[ "$CONFIG_ACTION" != "keep" ]]; then
1390
1587
  section "Bridge network mode"
@@ -1409,7 +1606,7 @@ if [[ "$CONFIG_ACTION" != "keep" ]]; then
1409
1606
  esac
1410
1607
 
1411
1608
  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"
1609
+ 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
1610
  else
1414
1611
  ok "Keeping existing secure config."
1415
1612
  NETWORK_MODE="$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_NETWORK_MODE")"
@@ -1434,10 +1631,10 @@ else
1434
1631
  fi
1435
1632
 
1436
1633
  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
1634
+ 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"
1635
+ elif [[ "$(extract_env_value "$SECURE_ENV_FILE" "BRIDGE_ENABLED_ENGINES")" != "$(IFS=,; printf '%s' "${SELECTED_ENGINES[*]}")" ]]; then
1439
1636
  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"
1637
+ 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
1638
  fi
1442
1639
  fi
1443
1640
 
@@ -1467,8 +1664,7 @@ BRIDGE_PORT="${BRIDGE_PORT:-8787}"
1467
1664
  section "Summary"
1468
1665
  rail_echo "Bridge mode: $NETWORK_MODE"
1469
1666
  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}"
1667
+ rail_echo "Harnesses: $(format_engine_list "${SELECTED_ENGINES[@]}")"
1472
1668
  rail_echo "Secure env: $SECURE_ENV_FILE"
1473
1669
  if [[ "$FLOW" == "quickstart" ]]; then
1474
1670
  rail_echo "${DIM}Tip: re-run with Manual mode for full control at each step.${RESET}"
@@ -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,52 @@ 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 engine = parse_bridge_runtime_engine(&normalized)
5381
+ .ok_or_else(|| format!("unsupported BRIDGE_ENABLED_ENGINES entry: {normalized}"))?;
5382
+ if seen.insert(engine) {
5383
+ parsed.push(engine);
5384
+ }
5385
+ }
5386
+
5387
+ if parsed.is_empty() {
5388
+ return Err(
5389
+ "BRIDGE_ENABLED_ENGINES must include one or more of: codex, opencode".to_string(),
5390
+ );
5391
+ }
5392
+
5393
+ Ok(parsed)
5394
+ }
5395
+
5396
+ fn parse_enabled_bridge_engines_env() -> Result<Option<Vec<BridgeRuntimeEngine>>, String> {
5397
+ let raw = match env::var("BRIDGE_ENABLED_ENGINES") {
5398
+ Ok(raw) => raw,
5399
+ Err(_) => return Ok(None),
5400
+ };
5401
+
5402
+ Ok(Some(parse_enabled_bridge_engines_csv(&raw)?))
5403
+ }
5404
+
5405
+ fn legacy_default_enabled_engines(
5406
+ requested_active_engine: BridgeRuntimeEngine,
5407
+ ) -> Vec<BridgeRuntimeEngine> {
5408
+ match requested_active_engine {
5409
+ BridgeRuntimeEngine::Codex => {
5410
+ vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode]
5411
+ }
5412
+ BridgeRuntimeEngine::Opencode => {
5413
+ vec![BridgeRuntimeEngine::Opencode, BridgeRuntimeEngine::Codex]
5414
+ }
5415
+ }
5416
+ }
5417
+
5351
5418
  impl BridgeRuntimeEngine {
5352
5419
  fn as_str(self) -> &'static str {
5353
5420
  match self {
@@ -7399,6 +7466,7 @@ mod tests {
7399
7466
  cli_bin: "cat".to_string(),
7400
7467
  opencode_cli_bin: "opencode".to_string(),
7401
7468
  active_engine: BridgeRuntimeEngine::Codex,
7469
+ enabled_engines: vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode],
7402
7470
  opencode_host: "127.0.0.1".to_string(),
7403
7471
  opencode_port: 4090,
7404
7472
  opencode_server_username: "opencode".to_string(),
@@ -7969,6 +8037,16 @@ mod tests {
7969
8037
  shutdown_test_backend(&state.backend).await;
7970
8038
  }
7971
8039
 
8040
+ #[test]
8041
+ fn parse_enabled_bridge_engines_csv_preserves_order_and_removes_duplicates() {
8042
+ let parsed =
8043
+ parse_enabled_bridge_engines_csv("opencode,codex,opencode").expect("engine csv");
8044
+ assert_eq!(
8045
+ parsed,
8046
+ vec![BridgeRuntimeEngine::Opencode, BridgeRuntimeEngine::Codex]
8047
+ );
8048
+ }
8049
+
7972
8050
  #[tokio::test]
7973
8051
  async fn bridge_capabilities_reflect_single_engine_state() {
7974
8052
  let hub = Arc::new(ClientHub::new());
@@ -8796,6 +8874,7 @@ mod tests {
8796
8874
  cli_bin: "codex".to_string(),
8797
8875
  opencode_cli_bin: "opencode".to_string(),
8798
8876
  active_engine: BridgeRuntimeEngine::Codex,
8877
+ enabled_engines: vec![BridgeRuntimeEngine::Codex, BridgeRuntimeEngine::Opencode],
8799
8878
  opencode_host: "127.0.0.1".to_string(),
8800
8879
  opencode_port: 4090,
8801
8880
  opencode_server_username: "opencode".to_string(),