From 56402b7a4712b2a3213b638308aee4f1d492dfe3 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Wed, 11 Nov 2026 15:14:13 +0800 Subject: [PATCH] Compact start-goal host selection gate Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../codex-app-host-command-registry-v0.md | 6 +- loopx/bootstrap_command_pack.py | 73 ++++++++++++++++--- .../renderers/start_goal_markdown.py | 18 ++++- .../test_start_goal_compact_projection.py | 71 +++++++++++++----- tests/host_surface_cli_probes.py | 11 +++ 5 files changed, 147 insertions(+), 32 deletions(-) diff --git a/docs/reference/protocols/codex-app-host-command-registry-v0.md b/docs/reference/protocols/codex-app-host-command-registry-v0.md index 4e478ea366..f95f714c58 100644 --- a/docs/reference/protocols/codex-app-host-command-registry-v0.md +++ b/docs/reference/protocols/codex-app-host-command-registry-v0.md @@ -252,8 +252,10 @@ cannot expose its automation tools, so the visible `/goal` owns continuation. used alongside an editor. The legacy `codex-ide` value remains accepted as a compatibility alias but is not offered by the selection gate. If the option is omitted, the command returns a read-only `host_surface_selection` gate with -exact rerun commands; it must not connect a project, write todos, activate a -host, or spend quota. +compact default choices and exact rerun commands for common hosts. Omitted +surfaces remain discoverable through `full_choices_detail_command`, which +reruns the same read-only gate with `--include-command-pack-detail`; neither +projection may connect a project, write todos, activate a host, or spend quota. ## Permission Boundary diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index a0bb132e71..e5c93669bd 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -92,6 +92,15 @@ "shell", "other-agent", ) +DEFAULT_HOST_SURFACE_SELECTION_CHOICES = ( + "codex-app", + "trae_app", + "codex-app-ssh", + "codex-cli-tui", + "claude-code", + "shell", + "other-agent", +) def _iter_string_leaves( @@ -382,9 +391,9 @@ def build_start_goal_host_surface_selection_packet( "shell": "manual shell or an explicitly configured external scheduler", "other-agent": "custom agent host using the returned activation contract", } - choices: list[dict[str, Any]] = [] - for host_surface in START_GOAL_HOST_SURFACES: - rerun_command = ( + + def rerun_command_for(host_surface: str, *, include_detail: bool) -> str: + return ( f"{render_cli_command_prefix(cli_bin=cli_bin, runtime_root=command_runtime_root)} " f"start-goal --guided " f"--project {shell_arg_for_host(resolved_project)}" @@ -402,20 +411,56 @@ def build_start_goal_host_surface_selection_packet( ) + f" --goal-text {shell_arg_for_host(normalized_goal_text)}" + render_optional_cli_arg("--display-name", display_name) - + (" --include-command-pack-detail" if include_command_pack_detail else "") + + (" --include-command-pack-detail" if include_detail else "") ) - choices.append( + + def detail_command() -> str: + return ( + f"{render_cli_command_prefix(cli_bin=cli_bin, runtime_root=command_runtime_root)} " + f"start-goal --guided " + f"--project {shell_arg_for_host(resolved_project)}" + + (f" --goal-id {shell_arg_for_host(goal_id)}" if goal_id else "") + + (f" --agent-id {shell_arg_for_host(agent_id)}" if agent_id else "") + + (f" --thread-id {shell_arg_for_host(thread_id)}" if thread_id else "") + + (" --new-peer" if new_peer else "") + + render_available_capability_args(available_capabilities) + + (" --fine-grained" if fine_grained else "") + + ( + f" --capability-route {shell_arg_for_host(capability_route)}" + if capability_route + else "" + ) + + f" --goal-text {shell_arg_for_host(normalized_goal_text)}" + + render_optional_cli_arg("--display-name", display_name) + + " --include-command-pack-detail" + ) + + all_choices: list[dict[str, Any]] = [] + for host_surface in START_GOAL_HOST_SURFACES: + all_choices.append( { "host_surface": host_surface, "description": host_descriptions[host_surface], - "rerun_command": rerun_command, + "rerun_command": rerun_command_for( + host_surface, + include_detail=include_command_pack_detail, + ), } ) + default_host_surfaces = set(DEFAULT_HOST_SURFACE_SELECTION_CHOICES) + choices = ( + all_choices + if include_command_pack_detail + else [ + choice + for choice in all_choices + if choice["host_surface"] in default_host_surfaces + ] + ) reason = ( - "host surface is required because Codex App automation, Codex App over SSH, " - "the Codex IDE plugin, Codex CLI, and Ark Managed Agent " - "have different continuation contracts" + "LoopX needs to know where this agent is running before it can start a durable goal." ) + detail = detail_command() gate = { "schema_version": HOST_SURFACE_SELECTION_SCHEMA_VERSION, "state": "selection_required", @@ -423,6 +468,12 @@ def build_start_goal_host_surface_selection_packet( "reason": reason, "required_cli_arg": "--host-surface ", "choices": choices, + "choice_projection": "complete" if include_command_pack_detail else "compact", + "all_host_surfaces": list(START_GOAL_HOST_SURFACES), + "omitted_choice_count": 0 + if include_command_pack_detail + else len(START_GOAL_HOST_SURFACES) - len(choices), + "full_choices_detail_command": None if include_command_pack_detail else detail, } transaction = { "schema_version": GUIDED_START_SCHEMA_VERSION, @@ -439,6 +490,9 @@ def build_start_goal_host_surface_selection_packet( "kind": "host_surface_selection_gate", "choices": choices, "purpose": "select the current host before planning, mutation, or loop activation", + "full_choices_detail_command": None + if include_command_pack_detail + else detail, } ], "idempotency_policy": {"safe_to_rerun_preview": True}, @@ -489,6 +543,7 @@ def build_start_goal_host_surface_selection_packet( "safety_contract": "#/safety_contract", "compatibility_message": "#/message", }, + compact_projection_default=not include_command_pack_detail, ) return payload diff --git a/loopx/presentation/renderers/start_goal_markdown.py b/loopx/presentation/renderers/start_goal_markdown.py index 762e1b68a0..948a36b08d 100644 --- a/loopx/presentation/renderers/start_goal_markdown.py +++ b/loopx/presentation/renderers/start_goal_markdown.py @@ -165,6 +165,21 @@ def actionable_shell_command(value: Any) -> str: host_gate = host_gate if isinstance(host_gate, dict) else {} host_gate_lines = "" if host_gate: + detail_command = host_gate.get("full_choices_detail_command") + omitted_count = host_gate.get("omitted_choice_count") + if isinstance(detail_command, str) and detail_command.strip(): + detail_line = ( + "\n\n" + "Need another host" + + ( + f" ({omitted_count} more)" + if isinstance(omitted_count, int) and omitted_count > 0 + else "" + ) + + f"? Run `{detail_command}`." + ) + else: + detail_line = "" choices = [ f"- `{choice.get('host_surface')}`: {choice.get('description')} " f"\n `{choice.get('rerun_command')}`" @@ -172,9 +187,10 @@ def actionable_shell_command(value: Any) -> str: if isinstance(choice, dict) ] host_gate_lines = ( - "\n## Host Surface Gate\n\n" + "\n## Choose Where This Agent Runs\n\n" f"{host_gate.get('reason')}\n\n" + "\n".join(choices) + + detail_line + "\n" ) return f"""# Guided Start Goal diff --git a/tests/control_plane/test_start_goal_compact_projection.py b/tests/control_plane/test_start_goal_compact_projection.py index 5c2dd23e67..3150f91a4e 100644 --- a/tests/control_plane/test_start_goal_compact_projection.py +++ b/tests/control_plane/test_start_goal_compact_projection.py @@ -2085,33 +2085,58 @@ def test_cli_without_host_returns_read_only_host_selection_gate( tmp_path: Path, ) -> None: project = _write_connected_project(tmp_path) - output = io.StringIO() - with contextlib.redirect_stdout(output): + base_args = [ + "--format", + "json", + "start-goal", + "--guided", + "--project", + str(project), + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--goal-text", + GOAL_TEXT, + "--capability-route", + "issue-fix", + ] + + compact_output = io.StringIO() + with contextlib.redirect_stdout(compact_output): exit_code = cli_main( - [ - "--format", - "json", - "start-goal", - "--guided", - "--project", - str(project), - "--goal-id", - GOAL_ID, - "--agent-id", - AGENT_ID, - "--goal-text", - GOAL_TEXT, - "--capability-route", - "issue-fix", - ] + base_args ) assert exit_code == 0 - payload = json.loads(output.getvalue()) + payload = json.loads(compact_output.getvalue()) assert payload["guided_transaction"]["blocked_by"] == "host_surface_selection" assert payload["safety_contract"]["writes_registry"] is False choices = payload["host_surface_selection_gate"]["choices"] assert [choice["host_surface"] for choice in choices] == [ + "codex-app", + "trae_app", + "codex-app-ssh", + "codex-cli-tui", + "claude-code", + "shell", + "other-agent", + ] + gate = payload["host_surface_selection_gate"] + assert gate["choice_projection"] == "compact" + assert gate["omitted_choice_count"] == 14 + assert "--include-command-pack-detail" in gate["full_choices_detail_command"] + assert payload["packet_summary"]["compatibility"]["compact_projection_default"] is True + assert "Choose Where This Agent Runs" in payload["message"] + assert "Need another host (14 more)?" in payload["message"] + + detailed_output = io.StringIO() + with contextlib.redirect_stdout(detailed_output): + detailed_exit = cli_main([*base_args, "--include-command-pack-detail"]) + assert detailed_exit == 0 + detailed = json.loads(detailed_output.getvalue()) + full_choices = detailed["host_surface_selection_gate"]["choices"] + assert [choice["host_surface"] for choice in full_choices] == [ "codex-app", "trae_app", "codex-app-ssh", @@ -2134,7 +2159,13 @@ def test_cli_without_host_returns_read_only_host_selection_gate( "shell", "other-agent", ] - ide = next(choice for choice in choices if choice["host_surface"] == "codex-ide-plugin") + assert detailed["host_surface_selection_gate"]["choice_projection"] == "complete" + assert detailed["host_surface_selection_gate"]["omitted_choice_count"] == 0 + ide = next( + choice + for choice in full_choices + if choice["host_surface"] == "codex-ide-plugin" + ) assert "--host-surface codex-ide-plugin" in ide["rerun_command"] assert "--capability-route issue-fix" in ide["rerun_command"] diff --git a/tests/host_surface_cli_probes.py b/tests/host_surface_cli_probes.py index 24bf399790..b329af1831 100644 --- a/tests/host_surface_cli_probes.py +++ b/tests/host_surface_cli_probes.py @@ -106,6 +106,17 @@ def selection_gate_offers_surface(surface: str, cwd: Path) -> None: assert gate_result.returncode == 0, gate_result.stderr gate = json.loads(gate_result.stdout)["host_surface_selection_gate"] choices = {item["host_surface"]: item for item in gate["choices"]} + if surface not in choices: + detail_command = gate.get("full_choices_detail_command") + assert isinstance(detail_command, str), sorted(choices) + detail_tokens = shlex.split(detail_command) + assert detail_tokens[0] == "loopx" + detail_result = run_cli(*detail_tokens[1:], cwd=cwd) + assert detail_result.returncode == 0, detail_result.stderr + detail_gate = json.loads(detail_result.stdout)["host_surface_selection_gate"] + assert detail_gate["choice_projection"] == "complete" + choices = {item["host_surface"]: item for item in detail_gate["choices"]} + assert surface in choices, sorted(choices) tokens = shlex.split(choices[surface]["rerun_command"])