Skip to content
Open
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
23 changes: 23 additions & 0 deletions docs/heartbeat-automation-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ automation contract.

### Native Goal bootstrap and live execution instructions

For an already connected local Codex App conversation whose user requests native
Goal continuation, select `codex_app_goal` explicitly:

```bash
loopx --format json --registry <GLOBAL_REGISTRY> heartbeat-prompt \
--bootstrap --thin --runtime-profile codex_app_goal \
--goal-id <STABLE_GOAL_ID> --agent-id <REGISTERED_AGENT_ID>
```

Use the successful returned `task_body` with the current App's native Goal
controls, preserving the existing objective and registered identity. The App
owns continuation; this route neither starts another Codex process nor creates
an automation. Existing blocked Goals require explicit host resume rather than
replacement. The runtime context is `codex_app` / `goal_runtime` / `interactive`;
quota settlement uses `visible-goal`. Follow the live guard's `--begin-turn`
re-entry when requested instead of assigning a synthetic native turn ID.

This is opt-in: `--codex-app` still means `codex_app_heartbeat`, and ordinary
`codex-app` onboarding keeps its existing automation behavior. A committed
LoopX receipt is not evidence of a native Goal trigger. Qualification must pair
the actual host turn with admitted work, writeback and settlement, then observe
native continuation in the same conversation.

Full, compact and brief automation use the same fully qualified notification/execution rule
as thin, including `heartbeat_recommendation.agent_must_attempt` and
`execution_obligation.must_attempt_work`. None embeds a second
Expand Down
2 changes: 1 addition & 1 deletion loopx/cli_commands/quota_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def validate_quota_command_context_request(
if profile not in GUIDED_START_TURN_RUNTIME_PROFILES:
raise QuotaCommandValidationError(
"--begin-turn requires runtime-profile codex_app_heartbeat, "
"trae_app, or codex_app_ssh_goal; every other host starts its turn by "
"trae_app, codex_app_goal, or codex_app_ssh_goal; every other host starts its turn by "
"passing its own --turn-instance-id"
)
if (
Expand Down
2 changes: 1 addition & 1 deletion loopx/control_plane/heartbeat/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def uses_native_goal_host_loop(
context = resolution.context
if context.execution_mode is not ExecutionMode.INTERACTIVE:
return False
if context.host_surface is HostSurface.ARK_MANAGED_AGENT:
if context.host_surface in {HostSurface.ARK_MANAGED_AGENT, HostSurface.CODEX_APP}:
return context.scheduler_owner is SchedulerOwner.GOAL_RUNTIME
return (
context.host_surface in {HostSurface.CODEX_APP_SSH, HostSurface.CODEX_CLI}
Expand Down
5 changes: 4 additions & 1 deletion loopx/control_plane/quota/spend_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ def host_goal_turn_reentry_action(
and settlement_plan is None
and turn_instance_id is None
):
if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE:
if profile in {
SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE,
SchedulerRuntimeProfile.CODEX_APP_NATIVE_GOAL,
}:
return f"{typed_quota_guard} --begin-turn"
# CLI/managed Goal hosts own their iteration identity, not an App
# heartbeat receipt. Re-enter before exposing any unbound settlement.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type Measurement = typeof MEASUREMENTS[number];
export type Host = typeof HOSTS[number];
export function hostCategory(value: unknown): Host {
if ((HOSTS as readonly unknown[]).includes(value)) return value as Host;
const aliases: Record<string, Host> = { "codex-app": "codex_app", "codex-app-ssh": "codex_app", codex_app: "codex_app", codex_app_heartbeat: "codex_app", codex_app_ssh_goal: "codex_app", "codex-cli": "codex_cli", "codex-cli-tui": "codex_cli", codex_cli: "codex_cli", codex: "codex_cli", "codex-ide-plugin": "codex_cli", "deepseek-harness-native": "dsh", "claude-code": "claude_code", claude_code: "claude_code", dsh: "dsh", opencode: "opencode", trae_app: "trae", "kiro-cli": "kiro_cli", "generic-cli": "other", generic_cli: "other" };
const aliases: Record<string, Host> = { "codex-app": "codex_app", "codex-app-ssh": "codex_app", codex_app: "codex_app", codex_app_heartbeat: "codex_app", codex_app_goal: "codex_app", codex_app_ssh_goal: "codex_app", "codex-cli": "codex_cli", "codex-cli-tui": "codex_cli", codex_cli: "codex_cli", codex: "codex_cli", "codex-ide-plugin": "codex_cli", "deepseek-harness-native": "dsh", "claude-code": "claude_code", claude_code: "claude_code", dsh: "dsh", opencode: "opencode", trae_app: "trae", "kiro-cli": "kiro_cli", "generic-cli": "other", generic_cli: "other" };
return typeof value === "string" && Object.hasOwn(aliases, value) ? aliases[value] : "unknown";
}
export type GoalCount = { measurement: Measurement; host: Host; span: GoalDuration; duration: GoalDuration; count: number };
Expand Down
20 changes: 17 additions & 3 deletions loopx/control_plane/scheduler/execution_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ExecutionMode(str, Enum):
class SchedulerRuntimeProfile(str, Enum):
ARK_MANAGED_AGENT_GOAL = "ark_managed_agent_goal"
CODEX_APP_HEARTBEAT = "codex_app_heartbeat"
CODEX_APP_NATIVE_GOAL = "codex_app_goal"
CODEX_APP_SSH_VISIBLE = "codex_app_ssh_goal"
CODEX_CLI_VISIBLE = "codex_cli"
TRAE_APP = "trae_app"
Expand All @@ -59,6 +60,7 @@ class SchedulerRuntimeProfile(str, Enum):
NATIVE_GOAL_RUNTIME_PROFILES = frozenset(
{
SchedulerRuntimeProfile.ARK_MANAGED_AGENT_GOAL,
SchedulerRuntimeProfile.CODEX_APP_NATIVE_GOAL,
SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE,
SchedulerRuntimeProfile.CODEX_CLI_VISIBLE,
}
Expand All @@ -83,6 +85,7 @@ class SchedulerRuntimeProfile(str, Enum):
GUIDED_START_TURN_RUNTIME_PROFILES = frozenset(
{
SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT,
SchedulerRuntimeProfile.CODEX_APP_NATIVE_GOAL,
SchedulerRuntimeProfile.TRAE_APP,
SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE,
}
Expand Down Expand Up @@ -112,6 +115,11 @@ class SchedulerRuntimeProfile(str, Enum):
SchedulerOwner.HOST_AUTOMATION,
ExecutionMode.HOSTED_AUTOMATION,
),
SchedulerRuntimeProfile.CODEX_APP_NATIVE_GOAL: (
HostSurface.CODEX_APP,
SchedulerOwner.GOAL_RUNTIME,
ExecutionMode.INTERACTIVE,
),
SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: (
HostSurface.CODEX_APP_SSH,
SchedulerOwner.AGENT_CLI_LOOP,
Expand Down Expand Up @@ -257,7 +265,10 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]:
HostSurface.KUNLUNCODE,
HostSurface.KIRO_CLI,
}
if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}:
if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} and not (
context.host_surface is HostSurface.CODEX_APP
and context.scheduler_owner is SchedulerOwner.GOAL_RUNTIME
):
host_name = context.host_surface.value
if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION:
errors.append(f"{host_name} requires scheduler_owner=host_automation")
Expand Down Expand Up @@ -299,8 +310,11 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]:
if context.execution_mode is ExecutionMode.HOSTED_AUTOMATION:
errors.append("agent_cli_loop cannot use execution_mode=hosted_automation")
if context.scheduler_owner is SchedulerOwner.GOAL_RUNTIME:
if context.host_surface is not HostSurface.ARK_MANAGED_AGENT:
errors.append("goal_runtime requires host_surface=ark_managed_agent")
if context.host_surface not in {
HostSurface.ARK_MANAGED_AGENT,
HostSurface.CODEX_APP,
}:
errors.append("goal_runtime requires host_surface=ark_managed_agent or codex_app")
if context.execution_mode is not ExecutionMode.INTERACTIVE:
errors.append("goal_runtime requires execution_mode=interactive")
if (
Expand Down
19 changes: 13 additions & 6 deletions loopx/control_plane/turn_driver/codex_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,21 @@ def context_delivery_activation(setting: str, *, runtime_profile: str | None = N
resolution = resolve_scheduler_execution_context(scheduler_execution_context)
context = resolution.context if resolution.ok else None
if context is not None:
# The machine's application row selects Codex. Other explicitly
# resolved hosts keep full delivery; an explicit CLI on still
# The machine preference leaves unsupported declared modes on
# full delivery; an explicit CLI on still
# rejects them. Unknown/conflicting declarations never bypass this
# adapter, and generic_cli still needs the benchmark declaration.
if codex_machine_preference and context.host_surface in {
HostSurface.ARK_MANAGED_AGENT, HostSurface.TRAE_APP, HostSurface.CLAUDE_CODE,
HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, HostSurface.LOCAL_SCHEDULER,
}:
if codex_machine_preference and (
context.host_surface in {
HostSurface.ARK_MANAGED_AGENT, HostSurface.TRAE_APP, HostSurface.CLAUDE_CODE,
HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, HostSurface.LOCAL_SCHEDULER,
}
or (
context.host_surface is HostSurface.CODEX_APP
and context.scheduler_owner is SchedulerOwner.GOAL_RUNTIME
and context.execution_mode is ExecutionMode.INTERACTIVE
)
):
return context_delivery_activation("off")
if (context.host_surface is HostSurface.CODEX_APP
and context.scheduler_owner is SchedulerOwner.HOST_AUTOMATION
Expand Down
19 changes: 10 additions & 9 deletions loopx/project_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
from .control_plane.progress_scope import AGENT_LANE_PROGRESS_SCOPE
from .control_plane.scheduler.execution_context import (
GENERIC_CLI_OUTER_CONTROLLER_SCHEDULER_CONTEXT,
SchedulerRuntimeProfile,
GUIDED_START_TURN_RUNTIME_PROFILES,
NATIVE_GOAL_RUNTIME_PROFILES,
render_scheduler_execution_args,
scheduler_execution_context_for_runtime_profile,
scheduler_runtime_profile_for_execution_context,
)
from .control_plane.todos.contract import normalize_required_capabilities
Expand Down Expand Up @@ -185,15 +187,14 @@ def render_quota_guard_command(
begin_turn: bool = False,
include_shared_registry: bool = True,
) -> str:
profile = scheduler_runtime_profile_for_execution_context(
scheduler_execution_context_for_runtime_profile(runtime_profile)
if runtime_profile is not None
else scheduler_execution_context
)
if not heartbeat_turn_receipt and agent_id and (
runtime_profile == SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE.value
or (
runtime_profile is None
and scheduler_runtime_profile_for_execution_context(
scheduler_execution_context
)
is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE
)
profile in NATIVE_GOAL_RUNTIME_PROFILES
and profile in GUIDED_START_TURN_RUNTIME_PROFILES
):
begin_turn = True
if heartbeat_turn_receipt and begin_turn:
Expand Down
31 changes: 31 additions & 0 deletions skills/loopx-project/references/heartbeat-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

Read before creating, repairing or changing a recurring host loop. Save only the stable loader and read its fresh result on every wake; the current body owns execution, runtime turn identity and settlement.

## Codex App Native Goal

When the user chooses native Goal continuation in an existing local Codex App
conversation, including Windows, load the bound Goal with this explicit profile:

```bash
loopx --format json --registry <GLOBAL_REGISTRY> heartbeat-prompt \
--bootstrap --thin --runtime-profile codex_app_goal \
--goal-id <STABLE_GOAL_ID> --agent-id <REGISTERED_AGENT_ID>
```

Require `ok=true` and use the returned `task_body` through the current App's
native Goal controls. Preserve the existing objective, identity and permissions;
do not clear or recreate an unfinished Goal merely to change its LoopX entry.
An already active Goal continues in the same conversation. A blocked Goal needs
the host's explicit user resume action; if that control is unavailable, report
the exact host limitation rather than starting another executor.

`codex_app_goal` means `codex_app` / `goal_runtime` / `interactive` and uses
`visible-goal` settlement. Each iteration follows the fresh interaction contract,
including `--begin-turn` when requested, and reuses its returned identity for
writeback and settlement. Do not invent a native turn ID or persist a batch ID
in the loader. Do not create a heartbeat automation, apply RRULE changes, or
switch to a separate Codex CLI session for this profile.

The existing `--codex-app` alias and `codex-app` onboarding remain heartbeat
defaults. They do not prove native Goal continuation. Verify the App's actual
Goal-triggered turn, admitted work, `visible-goal` settlement and subsequent
native continuation separately; a LoopX receipt alone does not establish the
host trigger.

## Set Up Recurring Heartbeats

When the exact host is Codex App connected to a remote workspace over SSH and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def test_machine_on_preserves_existing_worker_environment_off(tmp_path, monkeypa

@pytest.mark.parametrize("host", [
["--runtime-profile", profile] for profile in
("trae_app", "claude_code", "kunluncode", "kiro_cli", "ark_managed_agent_goal")
("trae_app", "claude_code", "kunluncode", "kiro_cli", "ark_managed_agent_goal", "codex_app_goal")
] + [["--host-surface", "claude_code", "--scheduler-owner", "agent_cli_loop",
"--execution-mode", "interactive"]])
def test_codex_machine_preference_preserves_other_declared_hosts(tmp_path, monkeypatch, host):
Expand Down
22 changes: 22 additions & 0 deletions tests/control_plane/test_heartbeat_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import json
import shlex
from pathlib import Path

import pytest
Expand Down Expand Up @@ -59,6 +60,27 @@ def test_heartbeat_output_and_format_precedence(tmp_path, global_format, local_f
assert "--runtime-root" in payload["task_body"]


def test_codex_app_goal_bootstrap_loads_the_live_native_contract(tmp_path):
registry, runtime = write_command_fixture(tmp_path)
args = ["--format=json", *heartbeat_args(registry, runtime),
"--runtime-profile=codex_app_goal", "--thin", "--bootstrap"]
bootstrap = run_cli_main("loopx.entrypoint", args)
assert bootstrap.returncode == 0, bootstrap.stderr
payload = json.loads(bootstrap.stdout)
assert payload["ok"] is True
command = shlex.split(payload["task_body"].split("```sh\n", 1)[1].split("\n```", 1)[0])
assert "--bootstrap" not in command
assert command[command.index("--runtime-profile") + 1] == "codex_app_goal"

loaded = run_cli_main("loopx.entrypoint", command[1:])
assert loaded.returncode == 0, loaded.stderr
prompt = json.loads(loaded.stdout)
assert prompt["ok"] is True
assert "--begin-turn" in prompt["quota_guard_command"]
assert "LOOPX_TURN=" not in prompt["task_body"]
assert "--source visible-goal" in prompt["quota_spend_command"]


def test_heartbeat_semantic_errors_keep_recovery_and_exit_status(tmp_path):
registry, runtime = write_command_fixture(tmp_path)
base = ["--format=json", *heartbeat_args(registry, runtime)]
Expand Down
Loading
Loading