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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 64 additions & 9 deletions loopx/bootstrap_command_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)}"
Expand All @@ -402,27 +411,69 @@ 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",
"action_required": True,
"reason": reason,
"required_cli_arg": "--host-surface <exact-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,
Expand All @@ -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},
Expand Down Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion loopx/presentation/renderers/start_goal_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,32 @@ 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')}`"
for choice in host_gate.get("choices") or []
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
Expand Down
71 changes: 51 additions & 20 deletions tests/control_plane/test_start_goal_compact_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"]

Expand Down
11 changes: 11 additions & 0 deletions tests/host_surface_cli_probes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading