From 9e0bd94321b230d0bfe577f7c97755b1ee76dc76 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 10:50:27 +0800 Subject: [PATCH] refactor(cli): retire the quota and support-control size pins via cohesive owners #4355 pinned `quota.py` (1118) and `support_control.py` (1015) at their current baseline while cohesive extractions land. This retires both pins by moving two genuinely cohesive owner groups out: * `quota_failure_report.py` (273) owns what gets logged, what the operator sees, and how a rejected request is reported without inventing success. * `support_control_chat.py` (155) owns the chat/dashboard parser registration. `quota.py` and `support_control.py` drop to 923 and 899 lines, both back under the default 1000-line budget, so the temporary pins are removed from the module-size smoke. Review follow-ups on the previous head: * Deleted two unused settlement-identity helpers (`_heartbeat_receipt_settlement_bindings`, `_effective_spend_turn_instance_id`) that had been copied into the new module while the live definitions stayed in `quota.py`. They had no caller, and a second copy of the quota settlement identity rule would drift on the next change to it. Their now-unused imports went with them. * Moved the `support_control_chat` import back into the module import block; it sat after `SUPPORT_CONTROL_COMMANDS`, which Ruff reported as `E402`. Verified: the module-size, support-control and control-plane modularization smokes pass; `chat`, `dashboard` and `quota` parse their arguments; the cli_commands suite is 72/72; the quota failure path renders unchanged; Ruff is clean on all four files. Signed-off-by: song --- ...-ownership-command-modularization-smoke.py | 2 - loopx/cli_commands/quota.py | 230 ++--------------- loopx/cli_commands/quota_failure_report.py | 233 ++++++++++++++++++ loopx/cli_commands/support_control.py | 143 +---------- loopx/cli_commands/support_control_chat.py | 155 ++++++++++++ 5 files changed, 413 insertions(+), 350 deletions(-) create mode 100644 loopx/cli_commands/quota_failure_report.py create mode 100644 loopx/cli_commands/support_control_chat.py diff --git a/examples/cli-command-module-size-ownership-command-modularization-smoke.py b/examples/cli-command-module-size-ownership-command-modularization-smoke.py index 62df7d098c..100f60ea68 100644 --- a/examples/cli-command-module-size-ownership-command-modularization-smoke.py +++ b/examples/cli-command-module-size-ownership-command-modularization-smoke.py @@ -12,8 +12,6 @@ STARTER_MODULE_LIMITS = { # Legacy command owners are frozen at their current baseline while each # cohesive extraction lands; the default budget still catches new growth. - "quota.py": 1118, - "support_control.py": 1015, "turn.py": 1114, "todo.py": 1098, "starter.py": 180, diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index ad5afd1944..1075fa5100 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -7,22 +7,20 @@ from ..capabilities.explore.composition_frontier import ( project_live_explore_composition_frontier, ) -from ..capabilities.repository_change_window import ( - repository_delivery_interaction_hook, -) from ..capabilities.periodic_report.pending_intent import ( periodic_report_pending_intent_interaction_hook, ) +from ..capabilities.repository_change_window import ( + repository_delivery_interaction_hook, +) +from ..control_plane.effect_runtime import EffectRuntimeRejected from ..control_plane.quota.cli_projection import ( compact_quota_monitor_poll_cli_payload, compact_quota_should_run_cli_payload, ) from ..control_plane.quota.effect_program import SettlementIdentity from ..control_plane.quota.error_codes import ( - HeartbeatReceiptIdentityConflictError, QuotaCommandValidationError, - QuotaIdentityPreconditionError, - quota_error_code, ) from ..control_plane.quota.heartbeat_receipt import ( HEARTBEAT_RECEIPT_SCHEMA_VERSION, @@ -43,16 +41,10 @@ render_existing_heartbeat_receipt_payload, ) from ..control_plane.quota.turn_envelope import build_turn_envelope -from ..control_plane.coordination.legacy_writer_fence import ( - LegacyCoordinationWriterFenced, -) -from ..control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable -from ..control_plane.effect_runtime import EffectRuntimeRejected from ..control_plane.scheduler.execution_context import ( GUIDED_START_TURN_RUNTIME_PROFILES, ) from ..control_plane.todos.contract import normalize_todo_id -from ..file_lock import lock_timeout_error_fields from ..presentation.renderers.quota_event_markdown import ( render_quota_monitor_poll_markdown, render_quota_slot_preview_markdown, @@ -83,6 +75,12 @@ prepare_quota_command_context, validate_quota_command_context_request, ) +from .quota_failure_report import ( + QUOTA_EVENT_KINDS, + quota_failure_payload, + quota_validation_failure_payload, + should_log_quota, +) from .quota_host_poll import attach_host_poll_receipt from .quota_monitor_poll import record_quota_monitor_poll_for_cli from .quota_registration import ( @@ -99,17 +97,6 @@ None, ] RolloutEventAppender = Callable[..., dict[str, object]] -QUOTA_EVENT_KINDS = { - "should-run": "quota_should_run", - "monitor-poll": "quota_monitor_poll", - "scheduler-ack": "quota_scheduler_ack", - "scheduler-ack-current": "quota_scheduler_ack", - "scheduler-fail-current": "quota_scheduler_failure", - "spend-slot": "quota_spend", - "void-slot": "quota_void", -} - - def _heartbeat_receipt_settlement_bindings( event: Mapping[str, object], ) -> tuple[str | None, str | None]: @@ -146,191 +133,6 @@ def _effective_spend_turn_instance_id( return payload_turn_id -def _should_log_quota(command: str, payload: Mapping[str, object]) -> bool: - return command in QUOTA_EVENT_KINDS and ( - command == "should-run" - or ( - bool(payload.get("ok")) - and ( - bool(payload.get("appended")) - or bool(payload.get("receipt_repair_required")) - ) - ) - ) - - -def _verbose_debug_fields(error: Exception, *, verbose: bool) -> dict[str, object]: - if not verbose: - return {} - return { - "verbose_debug": { - "error_type": type(error).__name__, - "error": str(error), - } - } - - -def _quota_failure_payload( - args: argparse.Namespace, - *, - registry_path: Path, - runtime_root_arg: str | None, - error: Exception, -) -> dict[str, object]: - command = args.quota_command - lock_timeout_fields = lock_timeout_error_fields(error) - verbose_debug = _verbose_debug_fields( - error, verbose=bool(getattr(args, "verbose", False)) - ) - if command not in QUOTA_EVENT_KINDS: - return { - "ok": False, - "mode": command, - "registry": str(registry_path), - "runtime_root": runtime_root_arg, - "error_code": quota_error_code(error), - "error": "quota collection failed", - "summary": { - "registered_goals": 0, - "health_blockers": 1, - "next_automatic_turn": None, - "states": {}, - }, - "groups": {}, - "health_items": [ - { - "goal_id": "loopx-quota", - "status": "quota_collection_failed", - "waiting_on": "codex", - "severity": "high", - "recommended_action": ( - "fix quota/status collection before spending automatic compute" - ), - "source": "quota", - } - ], - **verbose_debug, - **lock_timeout_fields, - } - - public_reason = ( - str(error) - if isinstance(error, HeartbeatReceiptIdentityConflictError) - else "quota collection failed" - ) - payload: dict[str, object] = { - "ok": False, - "mode": command, - "goal_id": args.goal_id, - "decision": "skip", - "should_run": False, - "error_code": quota_error_code(error), - "reason": public_reason, - "state": "blocked_health", - "waiting_on": "codex", - "status": "quota_collection_failed", - "source": "quota", - "recommended_action": ( - "fix quota/status collection before spending automatic compute" - ), - **verbose_debug, - **lock_timeout_fields, - } - if isinstance(error, QuotaIdentityPreconditionError): - payload.update( - { - "reason": str(error), - "status": "quota_identity_precondition_failed", - "identity_precondition": error.precondition.value, - "recommended_action": error.recommended_action, - } - ) - if error.agent_id is not None: - payload["agent_id"] = error.agent_id - elif isinstance(error, (LegacyCoordinationWriterFenced, LocalCoordinationAuthorityUnavailable)): - payload.update( - { - "error_code": error.code, - "reason": str(error), - **error.payload, - } - ) - if lock_timeout_fields: - payload["recommended_action"] = "inspect the lock holder before retrying" - if command == "monitor-poll": - payload.update( - { - "source": args.source, - "agent_id": args.agent_id, - "todo_id": args.todo_id, - "target_key": args.target_key, - "result_hash": args.result_hash, - "material_change": bool(args.material_change), - } - ) - elif command in {"scheduler-ack", "scheduler-ack-current"}: - payload.update( - { - "agent_id": args.agent_id, - "surface": args.surface, - "state_key": args.state_key, - "applied_rrule": args.applied_rrule, - } - ) - elif command == "scheduler-fail-current": - payload.update( - { - "agent_id": args.agent_id, - "surface": args.surface, - "state_key": args.state_key, - "failed_rrule": args.failed_rrule, - "failure_kind": args.failure_kind, - } - ) - return payload - - -def _quota_validation_failure_payload( - args: argparse.Namespace, - exc: QuotaCommandValidationError, - *, - registry_path: Path, - runtime_root_arg: str | None, -) -> dict[str, object]: - command = args.quota_command - if command not in QUOTA_EVENT_KINDS: - return { - "ok": False, - "mode": command, - "registry": str(registry_path), - "runtime_root": runtime_root_arg, - "error_code": "QUOTA_VALIDATION_FAILED", - "error": str(exc), - "summary": { - "registered_goals": 0, - "health_blockers": 0, - "next_automatic_turn": None, - "states": {}, - }, - "groups": {}, - "health_items": [], - } - return { - "ok": False, - "mode": command, - "goal_id": args.goal_id, - "decision": "skip", - "should_run": False, - "error_code": "QUOTA_VALIDATION_FAILED", - "reason": str(exc), - "state": "blocked_validation", - "waiting_on": "codex", - "status": "quota_validation_failed", - "source": "quota", - "recommended_action": "fix the command arguments before retrying", - } - - def _quota_renderer( args: argparse.Namespace, ) -> Callable[[dict[str, object]], str]: @@ -554,10 +356,10 @@ def _dispatch_quota_turn_start_hooks( agent_id=args.agent_id, ) if args.agent_id: + from ..capabilities.manager_context import turn_start_hook from ..control_plane.agents.capability_memory import ( extend_turn_start_dispatch as extend_capability_memory_dispatch, ) - from ..capabilities.manager_context import turn_start_hook from ..control_plane.capability_hooks import dispatch_turn_start_hooks from ..history import load_registry from ..paths import resolve_runtime_root @@ -576,7 +378,9 @@ def _dispatch_quota_turn_start_hooks( dispatch[key] = list(dispatch.get(key) or []) + list(context_dispatch.get(key) or []) for key in ("registered_count", "invoked_count"): dispatch[key] = int(dispatch.get(key) or 0) + int(context_dispatch.get(key) or 0) - from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch + from ..capabilities.periodic_report.cadence_runtime import ( + extend_cadence_turn_start_dispatch, + ) dispatch = extend_cadence_turn_start_dispatch(dispatch, registry_path=registry_path, runtime_root=root, goal_id=args.goal_id, agent_id=args.agent_id) local_private_state_mutated = any( @@ -892,20 +696,20 @@ def handle_quota_command( payload["status_projection_cache"] = cache_metadata except QuotaCommandValidationError as exc: # Only typed CLI validation diagnostics are public-safe by contract. - payload = _quota_validation_failure_payload( + payload = quota_validation_failure_payload( args, exc, registry_path=registry_path, runtime_root_arg=runtime_root_arg, ) except Exception as exc: # noqa: BLE001 - CLI fail-safe boundary; error_code is typed below. - payload = _quota_failure_payload( + payload = quota_failure_payload( args, registry_path=registry_path, runtime_root_arg=runtime_root_arg, error=exc, ) - if _should_log_quota(args.quota_command, payload): + if should_log_quota(args.quota_command, payload): spend_turn_instance_id = _effective_spend_turn_instance_id( payload, heartbeat_turn_id=heartbeat_turn_id, diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py new file mode 100644 index 0000000000..c3e97ea2a2 --- /dev/null +++ b/loopx/cli_commands/quota_failure_report.py @@ -0,0 +1,233 @@ +"""Owner-local failure reporting for the quota CLI command. + +Keeping the honest failure and validation payloads beside the command handler +pushed that module over its size budget. They form one cohesive unit: what gets +logged, what the operator sees, and how a rejected request is reported without +inventing success. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping +from pathlib import Path + +from ..control_plane.coordination.legacy_writer_fence import ( + LegacyCoordinationWriterFenced, +) +from ..control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, +) +from ..control_plane.quota.error_codes import ( + HeartbeatReceiptIdentityConflictError, + QuotaCommandValidationError, + QuotaIdentityPreconditionError, + quota_error_code, +) +from ..file_lock import lock_timeout_error_fields + +QUOTA_EVENT_KINDS = { + "should-run": "quota_should_run", + "monitor-poll": "quota_monitor_poll", + "scheduler-ack": "quota_scheduler_ack", + "scheduler-ack-current": "quota_scheduler_ack", + "scheduler-fail-current": "quota_scheduler_failure", + "spend-slot": "quota_spend", + "void-slot": "quota_void", +} + + +def should_log_quota(command: str, payload: Mapping[str, object]) -> bool: + return command in QUOTA_EVENT_KINDS and ( + command == "should-run" + or ( + bool(payload.get("ok")) + and ( + bool(payload.get("appended")) + or bool(payload.get("receipt_repair_required")) + ) + ) + ) + + +def verbose_debug_fields(error: Exception, *, verbose: bool) -> dict[str, object]: + if not verbose: + return {} + return { + "verbose_debug": { + "error_type": type(error).__name__, + "error": str(error), + } + } + + +def quota_failure_payload( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + error: Exception, +) -> dict[str, object]: + command = args.quota_command + lock_timeout_fields = lock_timeout_error_fields(error) + verbose_debug = verbose_debug_fields( + error, verbose=bool(getattr(args, "verbose", False)) + ) + if command not in QUOTA_EVENT_KINDS: + return { + "ok": False, + "mode": command, + "registry": str(registry_path), + "runtime_root": runtime_root_arg, + "error_code": quota_error_code(error), + "error": "quota collection failed", + "summary": { + "registered_goals": 0, + "health_blockers": 1, + "next_automatic_turn": None, + "states": {}, + }, + "groups": {}, + "health_items": [ + { + "goal_id": "loopx-quota", + "status": "quota_collection_failed", + "waiting_on": "codex", + "severity": "high", + "recommended_action": ( + "fix quota/status collection before spending automatic compute" + ), + "source": "quota", + } + ], + **verbose_debug, + **lock_timeout_fields, + } + + public_reason = ( + str(error) + if isinstance(error, HeartbeatReceiptIdentityConflictError) + else "quota collection failed" + ) + payload: dict[str, object] = { + "ok": False, + "mode": command, + "goal_id": args.goal_id, + "decision": "skip", + "should_run": False, + "error_code": quota_error_code(error), + "reason": public_reason, + "state": "blocked_health", + "waiting_on": "codex", + "status": "quota_collection_failed", + "source": "quota", + "recommended_action": ( + "fix quota/status collection before spending automatic compute" + ), + **verbose_debug, + **lock_timeout_fields, + } + if isinstance(error, QuotaIdentityPreconditionError): + payload.update( + { + "reason": str(error), + "status": "quota_identity_precondition_failed", + "identity_precondition": error.precondition.value, + "recommended_action": error.recommended_action, + } + ) + if error.agent_id is not None: + payload["agent_id"] = error.agent_id + elif isinstance(error, (LegacyCoordinationWriterFenced, LocalCoordinationAuthorityUnavailable)): + payload.update( + { + "error_code": error.code, + "reason": str(error), + **error.payload, + } + ) + if lock_timeout_fields: + payload["recommended_action"] = "inspect the lock holder before retrying" + if command == "monitor-poll": + payload.update( + { + "source": args.source, + "agent_id": args.agent_id, + "todo_id": args.todo_id, + "target_key": args.target_key, + "result_hash": args.result_hash, + "material_change": bool(args.material_change), + } + ) + elif command in {"scheduler-ack", "scheduler-ack-current"}: + payload.update( + { + "agent_id": args.agent_id, + "surface": args.surface, + "state_key": args.state_key, + "applied_rrule": args.applied_rrule, + } + ) + elif command == "scheduler-fail-current": + payload.update( + { + "agent_id": args.agent_id, + "surface": args.surface, + "state_key": args.state_key, + "failed_rrule": args.failed_rrule, + "failure_kind": args.failure_kind, + } + ) + return payload + + +def quota_validation_failure_payload( + args: argparse.Namespace, + exc: QuotaCommandValidationError, + *, + registry_path: Path, + runtime_root_arg: str | None, +) -> dict[str, object]: + command = args.quota_command + if command not in QUOTA_EVENT_KINDS: + return { + "ok": False, + "mode": command, + "registry": str(registry_path), + "runtime_root": runtime_root_arg, + "error_code": "QUOTA_VALIDATION_FAILED", + "error": str(exc), + "summary": { + "registered_goals": 0, + "health_blockers": 0, + "next_automatic_turn": None, + "states": {}, + }, + "groups": {}, + "health_items": [], + } + return { + "ok": False, + "mode": command, + "goal_id": args.goal_id, + "decision": "skip", + "should_run": False, + "error_code": "QUOTA_VALIDATION_FAILED", + "reason": str(exc), + "state": "blocked_validation", + "waiting_on": "codex", + "status": "quota_validation_failed", + "source": "quota", + "recommended_action": "fix the command arguments before retrying", + } + + + + +__all__ = [ + "QUOTA_EVENT_KINDS", + "quota_failure_payload", + "quota_validation_failure_payload", + "should_log_quota", + "verbose_debug_fields", +] diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 78e39d567c..df5fba80f2 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -16,8 +16,8 @@ DEFAULT_CHAT_PORT, serve_chat, ) -from ..control_plane.scheduler.execution_context import SchedulerRuntimeProfile from ..control_plane.reward_memory import reward_memory_goal_policy +from ..control_plane.scheduler.execution_context import SchedulerRuntimeProfile from ..dashboard_launcher import launch_dashboard, replace_existing_loopx_chat from ..execution_profile import execution_profile_turn_granularity from ..heartbeat_prequota import ( @@ -65,10 +65,9 @@ handle_backup_state_command, register_backup_state_command, ) -from .support_control_agent_runtime import register_agent_runtime_arguments +from .support_control_chat import register_chat_and_dashboard_commands from .support_control_chat_endpoint import ( handle_chat_endpoint_command, - register_chat_endpoint_command, ) from .support_control_heartbeat_registration import ( register_heartbeat_control_commands, @@ -108,6 +107,7 @@ } | SUPERVISOR_CONTROL_COMMANDS + def register_support_control_commands( subparsers: argparse._SubParsersAction, add_subcommand_format: AddFormat, @@ -316,136 +316,7 @@ def register_support_control_commands( serve_status_parser.add_argument( "--verbose", action="store_true", help="Print HTTP request logs." ) - - chat_parser = subparsers.add_parser( - "chat", - help="Open the local Goal Studio and review Agent-proposed LoopX Todos.", - ) - chat_parser.add_argument( - "--goal-id", help="Goal to select when the local workspace opens." - ) - chat_parser.add_argument( - "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." - ) - chat_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) - register_agent_runtime_arguments(chat_parser, kiro_cli_bin=KIRO_CLI_BIN) - chat_parser.add_argument( - "--startup-timeout-seconds", - type=float, - default=30.0, - help="Maximum seconds allowed for Codex app-server startup and handshake.", - ) - chat_parser.add_argument( - "--idle-timeout-seconds", - type=float, - default=180.0, - help="Maximum seconds without an upstream event before interrupting the active turn.", - ) - chat_parser.add_argument( - "--hard-timeout-seconds", - type=float, - default=900.0, - help="Absolute maximum seconds for one Agent turn.", - ) - chat_parser.add_argument( - "--assets-dir", - help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", - ) - chat_parser.add_argument( - "--scan-root", - default=default_public_scan_root(), - help="Public files used by the underlying status projection.", - ) - chat_parser.add_argument( - "--scan-path", - action="append", - default=[], - help="Specific public file or directory to scan. Repeatable.", - ) - chat_parser.add_argument("--limit", type=int, default=20) - chat_parser.add_argument( - "--global-registry", - action="store_true", - help="Use the shared global registry even when the command runs in a project directory.", - ) - chat_parser.add_argument( - "--enable-goal-subagent-configuration", - action="store_true", - help=( - "Enable the preview-locked Goal sub-agent configuration API, " - "status projection, and dashboard controls." - ), - ) - chat_parser.add_argument( - "--no-open", - action="store_true", - help="Start the local server without opening a browser.", - ) - chat_parser.add_argument( - "--replace-existing-loopx-chat", - action="store_true", - help=argparse.SUPPRESS, - ) - chat_parser.add_argument( - "--verbose", action="store_true", help="Print HTTP request logs." - ) - - register_chat_endpoint_command(subparsers, add_subcommand_format) - - dashboard_parser = subparsers.add_parser( - "dashboard", - help="Start the local LoopX dashboard, status service, and Chat service.", - ) - dashboard_parser.add_argument( - "--goal-id", help="Goal to select when the local workspace opens." - ) - dashboard_parser.add_argument( - "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." - ) - dashboard_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) - register_agent_runtime_arguments(dashboard_parser, kiro_cli_bin=KIRO_CLI_BIN) - dashboard_parser.add_argument( - "--assets-dir", - help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", - ) - dashboard_parser.add_argument( - "--scan-root", - default=default_public_scan_root(), - help="Public files used by the underlying status projection.", - ) - dashboard_parser.add_argument( - "--scan-path", - action="append", - default=[], - help="Specific public file or directory to scan. Repeatable.", - ) - dashboard_parser.add_argument("--limit", type=int, default=20) - dashboard_parser.add_argument( - "--global-registry", - action="store_true", - help="Use the shared global registry even when the command runs in a project directory.", - ) - dashboard_parser.add_argument( - "--enable-goal-subagent-configuration", - action="store_true", - help=( - "Enable the preview-locked Goal sub-agent configuration API, " - "status projection, and dashboard controls." - ), - ) - dashboard_parser.add_argument( - "--no-open", - action="store_true", - help="Start the local server without opening a browser.", - ) - dashboard_parser.add_argument( - "--dev", - action="store_true", - help="Prefer the Vite HMR dev launcher if running from a local repository checkout.", - ) - dashboard_parser.add_argument( - "--verbose", action="store_true", help="Print HTTP request logs." - ) + register_chat_and_dashboard_commands(subparsers, add_subcommand_format) def handle_support_control_command( @@ -460,7 +331,7 @@ def handle_support_control_command( return None if args.command == "automation-prompts": - from .automation_prompts import run, render + from .automation_prompts import render, run try: payload = run(args, registry_path) except Exception as error: @@ -828,7 +699,9 @@ def handle_support_control_command( if update_action is UpdateAction.APPLY and payload.get("plan", {}).get( "apply_supported" ): - from ..control_plane.heartbeat.installed_prompt_update import update_with_prompts + from ..control_plane.heartbeat.installed_prompt_update import ( + update_with_prompts, + ) payload = update_with_prompts( payload, registry=(registry_path if registry_was_supplied else explicit_global_registry(args.runtime_root)), runtime_root=args.runtime_root, diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py new file mode 100644 index 0000000000..fbbed1408a --- /dev/null +++ b/loopx/cli_commands/support_control_chat.py @@ -0,0 +1,155 @@ +"""Owner-local parser registration for the chat and dashboard commands. + +Both commands launch the same local presentation surface with different +defaults, so they share one cohesive registration unit. Keeping them beside +the other support commands pushed that module over its size budget. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable + +from ..chat_server import DEFAULT_CHAT_HOST, DEFAULT_CHAT_PORT +from ..kiro_cli_goal_mode import KIRO_CLI_BIN +from ..paths import default_public_scan_root +from .support_control_agent_runtime import register_agent_runtime_arguments +from .support_control_chat_endpoint import register_chat_endpoint_command + + +def register_chat_and_dashboard_commands( + subparsers: argparse._SubParsersAction, + add_subcommand_format: Callable[[argparse.ArgumentParser], None], +) -> None: + chat_parser = subparsers.add_parser( + "chat", + help="Open the local Goal Studio and review Agent-proposed LoopX Todos.", + ) + chat_parser.add_argument( + "--goal-id", help="Goal to select when the local workspace opens." + ) + chat_parser.add_argument( + "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." + ) + chat_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) + register_agent_runtime_arguments(chat_parser, kiro_cli_bin=KIRO_CLI_BIN) + chat_parser.add_argument( + "--startup-timeout-seconds", + type=float, + default=30.0, + help="Maximum seconds allowed for Codex app-server startup and handshake.", + ) + chat_parser.add_argument( + "--idle-timeout-seconds", + type=float, + default=180.0, + help="Maximum seconds without an upstream event before interrupting the active turn.", + ) + chat_parser.add_argument( + "--hard-timeout-seconds", + type=float, + default=900.0, + help="Absolute maximum seconds for one Agent turn.", + ) + chat_parser.add_argument( + "--assets-dir", + help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", + ) + chat_parser.add_argument( + "--scan-root", + default=default_public_scan_root(), + help="Public files used by the underlying status projection.", + ) + chat_parser.add_argument( + "--scan-path", + action="append", + default=[], + help="Specific public file or directory to scan. Repeatable.", + ) + chat_parser.add_argument("--limit", type=int, default=20) + chat_parser.add_argument( + "--global-registry", + action="store_true", + help="Use the shared global registry even when the command runs in a project directory.", + ) + chat_parser.add_argument( + "--enable-goal-subagent-configuration", + action="store_true", + help=( + "Enable the preview-locked Goal sub-agent configuration API, " + "status projection, and dashboard controls." + ), + ) + chat_parser.add_argument( + "--no-open", + action="store_true", + help="Start the local server without opening a browser.", + ) + chat_parser.add_argument( + "--replace-existing-loopx-chat", + action="store_true", + help=argparse.SUPPRESS, + ) + chat_parser.add_argument( + "--verbose", action="store_true", help="Print HTTP request logs." + ) + + register_chat_endpoint_command(subparsers, add_subcommand_format) + + dashboard_parser = subparsers.add_parser( + "dashboard", + help="Start the local LoopX dashboard, status service, and Chat service.", + ) + dashboard_parser.add_argument( + "--goal-id", help="Goal to select when the local workspace opens." + ) + dashboard_parser.add_argument( + "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." + ) + dashboard_parser.add_argument("--port", type=int, default=DEFAULT_CHAT_PORT) + register_agent_runtime_arguments(dashboard_parser, kiro_cli_bin=KIRO_CLI_BIN) + dashboard_parser.add_argument( + "--assets-dir", + help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", + ) + dashboard_parser.add_argument( + "--scan-root", + default=default_public_scan_root(), + help="Public files used by the underlying status projection.", + ) + dashboard_parser.add_argument( + "--scan-path", + action="append", + default=[], + help="Specific public file or directory to scan. Repeatable.", + ) + dashboard_parser.add_argument("--limit", type=int, default=20) + dashboard_parser.add_argument( + "--global-registry", + action="store_true", + help="Use the shared global registry even when the command runs in a project directory.", + ) + dashboard_parser.add_argument( + "--enable-goal-subagent-configuration", + action="store_true", + help=( + "Enable the preview-locked Goal sub-agent configuration API, " + "status projection, and dashboard controls." + ), + ) + dashboard_parser.add_argument( + "--no-open", + action="store_true", + help="Start the local server without opening a browser.", + ) + dashboard_parser.add_argument( + "--dev", + action="store_true", + help="Prefer the Vite HMR dev launcher if running from a local repository checkout.", + ) + dashboard_parser.add_argument( + "--verbose", action="store_true", help="Print HTTP request logs." + ) + + +__all__ = ["register_chat_and_dashboard_commands"]