Compare commits

...

4 Commits

Author SHA1 Message Date
Pat Sukprasert 161ea446f0 docs(harness): record PR 1.2 in the progress ledger
Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 22:18:46 +07:00
Pat Sukprasert a29acfb750 refactor(harness): normalize native launcher pass-through args (PR 1.2)
The 11 run_<x>_native launchers each spelled their pass-through arg
differently (claude_args, pi_args, ...). The provider seam needs one uniform
spelling to call them generically. Introduce extra_args as that spelling and
keep <x>_args as a back-compat alias.

- Add native_terminal.normalize_extra_args(): reconciles extra_args vs the
  legacy <x>_args alias — extra_args wins, the legacy alias emits a
  DeprecationWarning (removal targeted for 0.9.0), neither yields ().
- Give all 11 run_<x>_native entry points a keyword-only extra_args and make
  <x>_args an optional deprecated alias, normalizing at the top of each body
  so the deep internals keep using the existing local variable unchanged.
- Migrate the internal callers (resume_dispatch ×10, chat resume-redirect ×6,
  cli_native ×11) to extra_args so nothing in core trips the new warning; the
  alias exists purely for external back-compat.
- Tests: unit-cover the four normalize_extra_args branches. Existing native
  tests that still call <x>_args= now double as back-compat coverage.

No behavior change: with default warning filters the full native + hub suite
is green (verified the failure set is byte-identical to the clean tree; the
handful of red tests are pre-existing gateway-env artifacts unrelated to this
change).

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 22:18:45 +07:00
Pat Sukprasert 9e7f5a2acd docs(harness): add implementation-progress ledger (PR 1.1)
Add an append-only "Implementation progress" ledger to the modular-registry
proposal so each PR in the stack records its own status without editing the
plan tables (which would conflict across the 1.1→1.2→1.3 stack on every
rebase). Seed it with 1.1 (#3239, in review).

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 21:35:59 +07:00
Pat Sukprasert 680014c784 feat(harness): add NativeHarnessProvider seam foundation (PR 1.1)
First, additive step of Phase 1 of the modular native-harness registry
(designs/harness-modular-registry-proposal.md). Introduces the behavior
side-channel that later PRs will dispatch through; no hub is rewired yet, so
this changes no runtime behavior.

- Add `NativeHarnessProvider` (frozen dataclass of dotted import-path strings
  for a native harness's lifecycle hooks) and the `native_providers` field on
  `HarnessContribution`, plus `native_providers()` / `native_provider_for_key()`
  accessors.
- Populate 11 built-in provider rows uniformly from the `omnigent.<key>_native`
  module layout (`run_<key>_native`, `_materialize_<key>_agent_spec`, and the
  `_auto_create_<key>_terminal` builder re-exported from `omnigent.runner.native`).
  Hooks that are still runner closures / inline dispatch (interrupt, stop,
  spawn-env, bridge-dir) stay None until those hubs migrate onto the seam.
- Add `omnigent/native_dispatch.py`: a lazy, per-path-cached resolver over the
  existing `load_object`, with `resolve` / `resolve_hook` / `resolve_hook_for_key`
  so hubs resolve a hook instead of branching on `key == "<x>"`. Import hygiene
  preserved — provider rows hold strings; only the resolver imports the target
  modules, and only at dispatch time.
- Tests: provider rows cover every native agent 1:1, required hooks are set, and
  every populated built-in path actually resolves to a callable (guards against
  a typo'd path or renamed symbol); resolver colon/dot forms, caching, and
  unset-hook / unknown-key None paths.

The validator still rejects community native metadata (Phase 2 flips it).

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 21:30:01 +07:00
24 changed files with 452 additions and 57 deletions
@@ -353,6 +353,17 @@ Only starts once Phase 1 has every built-in running *through* the seam.
2.2 → 2.3 (web); the risk center is **PR 1.5**, where the `_supervise_*_bridges`
invariants live.
### Implementation progress
Append-only ledger — one line per PR as it opens, updated to `landed` on merge.
The plan tables above stay the stable target; this tracks what has actually
shipped. **12 PRs total** (Phase 1: 1.11.8, Phase 2: 2.12.4).
| PR | Status | Link |
|---|---|---|
| 1.1 Provider model + resolver | in review | #3239 |
| 1.2 Signature normalization | in review | (stacked on 1.1) |
## Risks and open questions
- **Runner extraction is the risk center.** The `_supervise_*_bridges` mirrors
+8 -1
View File
@@ -134,6 +134,9 @@ from omnigent.native_terminal import (
from omnigent.native_terminal import (
bind_session_runner as _bind_session_runner,
)
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import (
terminal_attach_url as _attach_url,
)
@@ -198,7 +201,8 @@ def run_antigravity_native(
*,
server: str | None,
session_id: str | None,
antigravity_args: tuple[str, ...] = (),
extra_args: tuple[str, ...] | None = None,
antigravity_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
command: str | None = None,
model: str | None = None,
@@ -237,6 +241,9 @@ def run_antigravity_native(
:returns: None after the terminal attach session ends.
:raises click.ClickException: If setup, launch, or attach fails.
"""
antigravity_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=antigravity_args, legacy_param="antigravity_args"
)
resolved_command = (command or agy_binary_path()).strip()
if not resolved_command:
raise click.ClickException("Antigravity command must not be empty.")
+6 -6
View File
@@ -1162,7 +1162,7 @@ def _run_claude_native_resume_redirect(
run_claude_native(
server=base_url,
session_id=conversation_id,
claude_args=(),
extra_args=(),
auto_open_conversation=auto_open_conversation,
)
@@ -1196,7 +1196,7 @@ def _run_codex_native_resume_redirect(
run_codex_native(
server=base_url,
session_id=conversation_id,
codex_args=(),
extra_args=(),
auto_open_conversation=auto_open_conversation,
)
@@ -1228,7 +1228,7 @@ def _run_pi_native_resume_redirect(
run_pi_native(
server=base_url,
session_id=conversation_id,
pi_args=(),
extra_args=(),
auto_open_conversation=auto_open_conversation,
)
@@ -1252,7 +1252,7 @@ def _run_kiro_native_resume_redirect(
run_kiro_native(
server=base_url,
session_id=conversation_id,
kiro_args=(),
extra_args=(),
auto_open_conversation=auto_open_conversation,
)
@@ -1292,7 +1292,7 @@ def _run_cursor_native_resume_redirect(
run_cursor_native(
server=base_url,
session_id=conversation_id,
cursor_args=(),
extra_args=(),
auto_open_conversation=auto_open_conversation,
)
@@ -1330,7 +1330,7 @@ def _run_kimi_native_resume_redirect(
run_kimi_native(
server=base_url,
session_id=conversation_id,
kimi_args=(),
extra_args=(),
auto_open_conversation=auto_open_conversation,
)
+8 -1
View File
@@ -102,6 +102,9 @@ from omnigent.native_terminal import (
from omnigent.native_terminal import (
bind_session_runner as _bind_session_runner,
)
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import (
terminal_attach_url as _attach_url,
)
@@ -507,7 +510,8 @@ def run_claude_native(
*,
server: str | None,
session_id: str | None,
claude_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
claude_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
command: str = _DEFAULT_CLAUDE_COMMAND,
use_claude_config: bool = False,
@@ -542,6 +546,9 @@ def run_claude_native(
:returns: None after the attach session ends.
:raises click.ClickException: If setup, launch, or attach fails.
"""
claude_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=claude_args, legacy_param="claude_args"
)
startup_profiler = startup_profiler or StartupProfiler.from_env(
name="omnigent claude",
env_var=_CLAUDE_STARTUP_PROFILE_ENV_VAR,
+11 -13
View File
@@ -244,7 +244,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
claude_args=_resolve_harness_startup_args(cfg, "claude-native", claude_args),
extra_args=_resolve_harness_startup_args(cfg, "claude-native", claude_args),
use_claude_config=use_claude_config,
auto_open_conversation=auto_open_conversation,
startup_profiler=startup_profiler,
@@ -358,7 +358,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
codex_args=_resolve_harness_startup_args(cfg, "codex-native", codex_args),
extra_args=_resolve_harness_startup_args(cfg, "codex-native", codex_args),
model=model,
prompt=prompt,
auto_open_conversation=auto_open_conversation,
@@ -463,7 +463,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
opencode_args=_resolve_harness_startup_args(cfg, "opencode-native", opencode_args),
extra_args=_resolve_harness_startup_args(cfg, "opencode-native", opencode_args),
model=model,
auto_open_conversation=auto_open_conversation,
)
@@ -553,7 +553,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
pi_args=_resolve_harness_startup_args(cfg, "pi-native", pi_args),
extra_args=_resolve_harness_startup_args(cfg, "pi-native", pi_args),
auto_open_conversation=auto_open_conversation,
)
@@ -668,7 +668,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
cursor_args=_resolve_harness_startup_args(cfg, "cursor-native", cursor_args),
extra_args=_resolve_harness_startup_args(cfg, "cursor-native", cursor_args),
model=model,
auto_open_conversation=auto_open_conversation,
mode=mode,
@@ -798,7 +798,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
kiro_args=launch_args,
extra_args=launch_args,
model=model,
prompt=prompt,
auto_open_conversation=auto_open_conversation,
@@ -887,7 +887,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
goose_args=_resolve_harness_startup_args(cfg, "goose-native", goose_args),
extra_args=_resolve_harness_startup_args(cfg, "goose-native", goose_args),
auto_open_conversation=auto_open_conversation,
)
@@ -974,7 +974,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
hermes_args=_resolve_harness_startup_args(cfg, "hermes-native", hermes_args),
extra_args=_resolve_harness_startup_args(cfg, "hermes-native", hermes_args),
auto_open_conversation=auto_open_conversation,
)
@@ -1073,9 +1073,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
antigravity_args=_resolve_harness_startup_args(
cfg, "antigravity-native", antigravity_args
),
extra_args=_resolve_harness_startup_args(cfg, "antigravity-native", antigravity_args),
model=model,
auto_open_conversation=auto_open_conversation,
command=resolved_command or None,
@@ -1164,7 +1162,7 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
qwen_args=_resolve_harness_startup_args(cfg, "qwen-native", qwen_args),
extra_args=_resolve_harness_startup_args(cfg, "qwen-native", qwen_args),
auto_open_conversation=auto_open_conversation,
)
@@ -1260,6 +1258,6 @@ def register_native_commands(cli: click.Group) -> None:
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
kimi_args=_resolve_harness_startup_args(cfg, "kimi-native", kimi_args),
extra_args=_resolve_harness_startup_args(cfg, "kimi-native", kimi_args),
auto_open_conversation=auto_open_conversation,
)
+8 -1
View File
@@ -85,6 +85,9 @@ from omnigent.native_terminal import (
from omnigent.native_terminal import (
bind_session_runner as _bind_session_runner,
)
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import (
terminal_attach_url as _attach_url,
)
@@ -328,7 +331,8 @@ def run_codex_native(
*,
server: str | None,
session_id: str | None,
codex_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
codex_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
command: str = _DEFAULT_CODEX_COMMAND,
model: str | None = None,
@@ -352,6 +356,9 @@ def run_codex_native(
:returns: None after the terminal attach session ends.
:raises click.ClickException: If setup fails.
"""
codex_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=codex_args, legacy_param="codex_args"
)
resolved_command = command.strip()
if not resolved_command:
raise click.ClickException("Codex command must not be empty.")
+8 -1
View File
@@ -52,6 +52,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
_DEFAULT_CURSOR_COMMAND = "cursor-agent"
@@ -272,7 +275,8 @@ def run_cursor_native(
*,
server: str | None,
session_id: str | None,
cursor_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
cursor_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
model: str | None = None,
auto_open_conversation: bool = False,
@@ -294,6 +298,9 @@ def run_cursor_native(
Injected as ``--mode <mode>`` unless already present in *cursor_args*.
:returns: None after the terminal attach session ends.
"""
cursor_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=cursor_args, legacy_param="cursor_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+8 -1
View File
@@ -54,6 +54,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
_DEFAULT_GOOSE_COMMAND = "goose"
@@ -153,7 +156,8 @@ def run_goose_native(
*,
server: str | None,
session_id: str | None,
goose_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
goose_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
@@ -168,6 +172,9 @@ def run_goose_native(
URL after launch.
:returns: None after the terminal attach session ends.
"""
goose_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=goose_args, legacy_param="goose_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+84
View File
@@ -67,6 +67,30 @@ class NativeCodingAgent:
}
@dataclass(frozen=True)
class NativeHarnessProvider:
"""Import paths for a native harness's lifecycle hooks.
``NativeCodingAgent`` is pure identity data; behavior lives here as a
sibling row keyed by the same ``key``. Every value is a dotted import path
(``module:attr`` or ``module.attr``) resolved lazily at dispatch time via
:mod:`omnigent.native_dispatch`, so building the registry never imports the
runner / CLI / native-harness stack. Optional hooks are ``None`` when the
behavior is not yet a module-level function the resolver can reach (e.g.
interrupt/stop handlers that are still runner closures, or the inline
spawn-env dispatch); those hubs migrate onto the seam in later phases.
"""
key: str # matches NativeCodingAgent.key
run_native: str # CLI + resume launch entry point
auto_create_terminal: str # runner terminal builder
spawn_env_builder: str | None = None
interrupt_handler: str | None = None
stop_handler: str | None = None
materialize_agent_spec: str | None = None # built-in agent seeding
bridge_dir: str | None = None # cost-popup bridge-dir lookup
@dataclass(frozen=True)
class HarnessContribution:
"""One package's harness registry contribution."""
@@ -77,6 +101,7 @@ class HarnessContribution:
aliases: dict[str, str] = field(default_factory=dict)
native_harnesses: frozenset[str] = frozenset()
native_agents: tuple[NativeCodingAgent, ...] = ()
native_providers: tuple[NativeHarnessProvider, ...] = ()
install_specs: dict[str, HarnessInstallSpec] = field(default_factory=dict)
harness_install_keys: dict[str, str] = field(default_factory=dict)
model_env_keys: dict[str, str] = field(default_factory=dict)
@@ -199,6 +224,48 @@ HERMES_NATIVE_CODING_AGENT = NativeCodingAgent(
)
def _builtin_native_provider(key: str) -> NativeHarnessProvider:
"""Build a built-in provider row from the ``omnigent.<key>_native`` module.
The built-in native harnesses follow a uniform module layout: each exports
``run_<key>_native`` (CLI + resume launch) and ``_materialize_<key>_agent_spec``
(agent seeding), and re-exports ``_auto_create_<key>_terminal`` from
``omnigent.runner.native``. The remaining hooks (spawn-env, interrupt, stop,
bridge-dir) are still runner-local closures / inline dispatch, so they stay
``None`` until those hubs migrate onto the seam.
"""
module = f"omnigent.{key}_native"
return NativeHarnessProvider(
key=key,
run_native=f"{module}:run_{key}_native",
auto_create_terminal=f"omnigent.runner.native:_auto_create_{key}_terminal",
materialize_agent_spec=f"{module}:_materialize_{key}_agent_spec",
)
# Behavior side-channel for the built-in native agents. One row per
# NativeCodingAgent above, keyed by the same ``key``; resolved lazily so this
# module stays import-light. See designs/harness-modular-registry-proposal.md
# (Phase 1). Populated uniformly because every built-in native harness shares
# the omnigent.<key>_native module layout.
_BUILTIN_NATIVE_PROVIDERS: tuple[NativeHarnessProvider, ...] = tuple(
_builtin_native_provider(agent.key)
for agent in (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
PI_NATIVE_CODING_AGENT,
OPENCODE_NATIVE_CODING_AGENT,
CURSOR_NATIVE_CODING_AGENT,
KIRO_NATIVE_CODING_AGENT,
GOOSE_NATIVE_CODING_AGENT,
ANTIGRAVITY_NATIVE_CODING_AGENT,
QWEN_NATIVE_CODING_AGENT,
KIMI_NATIVE_CODING_AGENT,
HERMES_NATIVE_CODING_AGENT,
)
)
# Declared capabilities for the built-in harnesses. Each value is backed by the
# module that implements it; the derivable axes (model_family, subagents) are
# asserted against their source in tests/test_harness_capabilities.py so the
@@ -617,6 +684,7 @@ _BUILTIN_CONTRIBUTION = HarnessContribution(
KIMI_NATIVE_CODING_AGENT,
HERMES_NATIVE_CODING_AGENT,
),
native_providers=_BUILTIN_NATIVE_PROVIDERS,
model_env_keys={
"acp": "HARNESS_ACP_MODEL",
"antigravity": "HARNESS_ANTIGRAVITY_MODEL",
@@ -846,6 +914,22 @@ def native_agents() -> tuple[NativeCodingAgent, ...]:
return tuple(agents)
def native_providers() -> tuple[NativeHarnessProvider, ...]:
"""Return native-harness behavior provider rows, merged across contributions."""
providers: list[NativeHarnessProvider] = []
for contribution in plugin_state().contributions:
providers.extend(contribution.native_providers)
return tuple(providers)
def native_provider_for_key(key: str) -> NativeHarnessProvider | None:
"""Return the provider row whose ``key`` matches, or ``None``."""
for provider in native_providers():
if provider.key == key:
return provider
return None
def harness_modules() -> dict[str, str]:
"""Return runtime harness module mapping, aliases included."""
modules = _merge_dict("harness_modules")
+8 -1
View File
@@ -53,6 +53,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
_DEFAULT_HERMES_COMMAND = "hermes"
@@ -152,7 +155,8 @@ def run_hermes_native(
*,
server: str | None,
session_id: str | None,
hermes_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
hermes_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
@@ -167,6 +171,9 @@ def run_hermes_native(
URL after launch.
:returns: None after the terminal attach session ends.
"""
hermes_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=hermes_args, legacy_param="hermes_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+8 -1
View File
@@ -51,6 +51,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
_DEFAULT_KIMI_COMMAND = "kimi"
@@ -157,7 +160,8 @@ def run_kimi_native(
*,
server: str | None,
session_id: str | None,
kimi_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
kimi_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
@@ -172,6 +176,9 @@ def run_kimi_native(
conversation URL after launch.
:returns: None after the terminal attach session ends.
"""
kimi_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=kimi_args, legacy_param="kimi_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+8 -1
View File
@@ -39,6 +39,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
_DEFAULT_KIRO_COMMAND = "kiro-cli"
@@ -186,13 +189,17 @@ def run_kiro_native(
*,
server: str | None,
session_id: str | None,
kiro_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
kiro_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
model: str | None = None,
prompt: str | None = None,
auto_open_conversation: bool = False,
) -> None:
"""Launch the Kiro TUI in an Omnigent terminal."""
kiro_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=kiro_args, legacy_param="kiro_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+63
View File
@@ -0,0 +1,63 @@
"""Lazy resolver for native-harness provider hooks.
``NativeHarnessProvider`` rows hold dotted import *strings*, never live
callables building the harness registry must not import the runner / CLI /
native-harness stack (see designs/harness-plugin-interface.md § import rules).
This module is the single place those strings become callables, and only at
dispatch time. Each dispatch hub (resume, CLI, runner launch/interrupt/stop,
seeding) resolves its hook here instead of branching on ``key == "<x>"``.
Resolution is cached per import path so a hot dispatch loop imports each target
module at most once.
"""
from __future__ import annotations
from typing import Any
from omnigent.harness_plugins import (
NativeHarnessProvider,
load_object,
native_provider_for_key,
)
_RESOLVE_CACHE: dict[str, Any] = {}
def resolve(import_path: str) -> Any:
"""Resolve a ``module:attr`` / ``module.attr`` path to its object, cached.
Thin caching wrapper over :func:`omnigent.harness_plugins.load_object`.
"""
cached = _RESOLVE_CACHE.get(import_path)
if cached is None:
cached = load_object(import_path)
_RESOLVE_CACHE[import_path] = cached
return cached
def resolve_hook(provider: NativeHarnessProvider, hook: str) -> Any | None:
"""Resolve one named hook on a provider, or ``None`` if it is unset.
``hook`` is a field name on :class:`NativeHarnessProvider` (e.g.
``"run_native"``, ``"auto_create_terminal"``). Optional hooks that the
provider leaves ``None`` resolve to ``None`` rather than raising, so callers
can treat "no such hook yet" and "hook present" uniformly.
"""
import_path = getattr(provider, hook)
if import_path is None:
return None
return resolve(import_path)
def resolve_hook_for_key(key: str, hook: str) -> Any | None:
"""Resolve a hook by native-agent ``key``, or ``None`` if unknown/unset."""
provider = native_provider_for_key(key)
if provider is None:
return None
return resolve_hook(provider, hook)
def reset_resolve_cache_for_tests() -> None:
"""Clear the per-path resolution cache."""
_RESOLVE_CACHE.clear()
+33
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import urllib.parse
import warnings
import click
import httpx
@@ -14,6 +15,38 @@ DAEMON_RUNNER_ONLINE_TIMEOUT_S = 60.0
DAEMON_TERMINAL_READY_TIMEOUT_S = 60.0
def normalize_extra_args(
*,
extra_args: tuple[str, ...] | None,
legacy_args: tuple[str, ...] | None,
legacy_param: str,
) -> tuple[str, ...]:
"""Reconcile a native launcher's pass-through args from both spellings.
Every ``run_<x>_native`` entry point accepts a uniform keyword-only
``extra_args`` (the spelling the provider seam calls with) plus its legacy
``<x>_args`` alias. This collapses the two into one tuple:
- only ``extra_args`` set use it;
- only the legacy alias set use it, emitting a ``DeprecationWarning``;
- both set ``extra_args`` wins (the legacy alias is ignored) with a warning;
- neither set empty tuple.
The ``<x>_args`` alias is deprecated and slated for removal in 0.9.0; callers
should pass ``extra_args``.
"""
if legacy_args is not None:
warnings.warn(
f"{legacy_param!r} is deprecated; pass extra_args instead "
"(the alias is removed in 0.9.0)",
DeprecationWarning,
stacklevel=3,
)
if extra_args is None:
return tuple(legacy_args)
return tuple(extra_args or ())
def url_component(value: str) -> str:
"""
Percent-encode a value for a path component.
+8 -1
View File
@@ -55,6 +55,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
from omnigent.opencode_native_state import read_launch_state, write_launch_state
@@ -155,7 +158,8 @@ def run_opencode_native( # pragma: no cover
*,
server: str | None,
session_id: str | None,
opencode_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
opencode_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
model: str | None = None,
auto_open_conversation: bool = False,
@@ -177,6 +181,9 @@ def run_opencode_native( # pragma: no cover
:param auto_open_conversation: Open the browser conversation URL on launch.
:returns: None after the terminal attach session ends.
"""
opencode_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=opencode_args, legacy_param="opencode_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+8 -1
View File
@@ -40,6 +40,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
from omnigent.pi_native_bridge import bridge_dir_for_session_id
@@ -198,7 +201,8 @@ def run_pi_native(
*,
server: str | None,
session_id: str | None,
pi_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
pi_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
@@ -213,6 +217,9 @@ def run_pi_native(
conversation URL after launch.
:returns: None after the terminal attach session ends.
"""
pi_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=pi_args, legacy_param="pi_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+8 -1
View File
@@ -54,6 +54,9 @@ from omnigent.native_terminal import (
DAEMON_TERMINAL_READY_TIMEOUT_S as _DAEMON_TERMINAL_READY_TIMEOUT_S,
)
from omnigent.native_terminal import bind_session_runner as _bind_session_runner
from omnigent.native_terminal import (
normalize_extra_args as _normalize_extra_args,
)
from omnigent.native_terminal import url_component
_DEFAULT_QWEN_COMMAND = "qwen"
@@ -152,7 +155,8 @@ def run_qwen_native(
*,
server: str | None,
session_id: str | None,
qwen_args: tuple[str, ...],
extra_args: tuple[str, ...] | None = None,
qwen_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
auto_open_conversation: bool = False,
) -> None:
@@ -167,6 +171,9 @@ def run_qwen_native(
URL after launch.
:returns: None after the terminal attach session ends.
"""
qwen_args = _normalize_extra_args(
extra_args=extra_args, legacy_args=qwen_args, legacy_param="qwen_args"
)
_preflight_local_tools()
if server is None:
raise click.ClickException(
+10 -10
View File
@@ -222,7 +222,7 @@ def _dispatch_wrapper(
run_claude_native(
server=server,
session_id=session_id,
claude_args=(),
extra_args=(),
)
return True
if native_agent.key == "codex":
@@ -231,7 +231,7 @@ def _dispatch_wrapper(
run_codex_native(
server=server,
session_id=session_id,
codex_args=(),
extra_args=(),
)
return True
if native_agent.key == "pi":
@@ -240,7 +240,7 @@ def _dispatch_wrapper(
run_pi_native(
server=server,
session_id=session_id,
pi_args=(),
extra_args=(),
)
return True
if native_agent.key == "cursor":
@@ -249,7 +249,7 @@ def _dispatch_wrapper(
run_cursor_native(
server=server,
session_id=session_id,
cursor_args=(),
extra_args=(),
)
return True
if native_agent.key == "kiro":
@@ -258,7 +258,7 @@ def _dispatch_wrapper(
run_kiro_native(
server=server,
session_id=session_id,
kiro_args=(),
extra_args=(),
)
return True
if native_agent.key == "goose":
@@ -267,7 +267,7 @@ def _dispatch_wrapper(
run_goose_native(
server=server,
session_id=session_id,
goose_args=(),
extra_args=(),
)
return True
if native_agent.key == "antigravity":
@@ -276,7 +276,7 @@ def _dispatch_wrapper(
run_antigravity_native(
server=server,
session_id=session_id,
antigravity_args=(),
extra_args=(),
)
return True
if native_agent.key == "qwen":
@@ -285,7 +285,7 @@ def _dispatch_wrapper(
run_qwen_native(
server=server,
session_id=session_id,
qwen_args=(),
extra_args=(),
)
return True
if native_agent.key == "kimi":
@@ -294,7 +294,7 @@ def _dispatch_wrapper(
run_kimi_native(
server=server,
session_id=session_id,
kimi_args=(),
extra_args=(),
)
return True
if native_agent.key == "hermes":
@@ -303,7 +303,7 @@ def _dispatch_wrapper(
run_hermes_native(
server=server,
session_id=session_id,
hermes_args=(),
extra_args=(),
)
return True
return False
+2 -2
View File
@@ -97,7 +97,7 @@ def test_redirect_native_resume_routes_kiro_wrapper(monkeypatch: pytest.MonkeyPa
assert captured == {
"server": "https://example.com",
"session_id": "conv_kiro",
"kiro_args": (),
"extra_args": (),
"auto_open_conversation": True,
}
@@ -3814,7 +3814,7 @@ def test_redirect_native_resume_handles_cursor(monkeypatch: pytest.MonkeyPatch)
assert captured == {
"server": "https://example.com",
"session_id": "conv_abc123",
"cursor_args": (),
"extra_args": (),
"auto_open_conversation": True,
}
+8 -8
View File
@@ -432,7 +432,7 @@ def test_claude_command_resume_binds_session_and_passes_unknown_args(
# there).
assert captured["server"] == "https://example.com"
assert captured["session_id"] == "conv_abc"
assert captured["claude_args"] == ("--resume", "claude-session", "-p", "say hi")
assert captured["extra_args"] == ("--resume", "claude-session", "-p", "say hi")
# No picker requested when ``--resume`` carries a value.
assert captured["resume_picker"] is False
# Default: Databricks auth is active (``--use-native-config`` not set) —
@@ -465,7 +465,7 @@ def test_claude_command_short_r_binds_omnigent_session(
assert result.exit_code == 0, result.output
assert captured["session_id"] == "conv_abc"
# ``-r <conv_id>`` consumes both tokens; no leftover claude args.
assert captured["claude_args"] == ()
assert captured["extra_args"] == ()
assert captured["resume_picker"] is False
@@ -662,7 +662,7 @@ def test_codex_command_resume_binds_session_and_passes_unknown_args(
assert result.exit_code == 0, result.output
assert captured["server"] == "https://example.com"
assert captured["session_id"] == "conv_abc"
assert captured["codex_args"] == ("-c", "approval_policy=on-request")
assert captured["extra_args"] == ("-c", "approval_policy=on-request")
assert captured["model"] == "gpt-test"
assert captured["prompt"] == "say hi"
assert captured["resume_picker"] is False
@@ -876,7 +876,7 @@ def test_codex_config_args_form_base_cli_args_append(
result = CliRunner().invoke(cli, ["codex", "--dangerously-skip-permissions"])
assert result.exit_code == 0, result.output
assert captured["codex_args"] == (
assert captured["extra_args"] == (
"--config",
"k=v",
"--dangerously-skip-permissions",
@@ -901,7 +901,7 @@ def test_codex_config_args_only_when_no_cli_args(
result = CliRunner().invoke(cli, ["codex"])
assert result.exit_code == 0, result.output
assert captured["codex_args"] == ("--verbose",)
assert captured["extra_args"] == ("--verbose",)
def test_codex_args_no_config_is_cli_args_only(
@@ -919,7 +919,7 @@ def test_codex_args_no_config_is_cli_args_only(
result = CliRunner().invoke(cli, ["codex", "--flag"])
assert result.exit_code == 0, result.output
assert captured["codex_args"] == ("--flag",)
assert captured["extra_args"] == ("--flag",)
def test_pi_config_args_form_base_cli_args_append(
@@ -940,7 +940,7 @@ def test_pi_config_args_form_base_cli_args_append(
result = CliRunner().invoke(cli, ["pi", "--cli-flag"])
assert result.exit_code == 0, result.output
assert captured["pi_args"] == ("--base", "--cli-flag")
assert captured["extra_args"] == ("--base", "--cli-flag")
def test_kiro_command_is_registered_in_click_help() -> None:
@@ -988,7 +988,7 @@ def test_kiro_command_parses_native_options_and_prompt(
assert captured["resume_picker"] is False
assert captured["model"] == "auto"
assert captured["prompt"] == "hi"
assert captured["kiro_args"] == (
assert captured["extra_args"] == (
"--effort",
"high",
"--agent",
+41
View File
@@ -216,3 +216,44 @@ def test_community_namespace_imports_external_harness_package(
module = importlib.import_module("omnigent.community.harness.foo")
assert module.VALUE == "ok"
def test_builtin_native_providers_cover_every_native_agent() -> None:
"""Every native agent has exactly one provider row keyed by the same key."""
agent_keys = sorted(agent.key for agent in hp.native_agents())
provider_keys = sorted(provider.key for provider in hp.native_providers())
assert provider_keys == agent_keys
# No duplicate provider keys.
assert len(provider_keys) == len(set(provider_keys))
def test_native_provider_for_key_lookup() -> None:
assert hp.native_provider_for_key("claude") is not None
assert hp.native_provider_for_key("claude").key == "claude"
assert hp.native_provider_for_key("does-not-exist") is None
def test_builtin_native_providers_have_required_hooks() -> None:
"""run_native and auto_create_terminal are mandatory on every built-in row."""
for provider in hp.native_providers():
assert provider.run_native, provider.key
assert provider.auto_create_terminal, provider.key
def test_builtin_native_provider_paths_resolve() -> None:
"""Every populated built-in provider hook resolves to a real callable.
This is the guard that keeps the provider rows honest: a typo'd import path
or a renamed run_<x>_native symbol fails here rather than at dispatch time.
"""
from omnigent import native_dispatch
native_dispatch.reset_resolve_cache_for_tests()
for provider in hp.native_providers():
for hook in (
"run_native",
"auto_create_terminal",
"materialize_agent_spec",
):
resolved = native_dispatch.resolve_hook(provider, hook)
assert callable(resolved), f"{provider.key}.{hook} did not resolve to a callable"
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from collections.abc import Iterator
import pytest
import omnigent.harness_plugins as hp
from omnigent import native_dispatch
@pytest.fixture(autouse=True)
def _reset_caches() -> Iterator[None]:
hp.reset_plugin_state_for_tests()
native_dispatch.reset_resolve_cache_for_tests()
yield
hp.reset_plugin_state_for_tests()
native_dispatch.reset_resolve_cache_for_tests()
def test_resolve_colon_and_dot_paths() -> None:
# module:attr form
assert native_dispatch.resolve("omnigent.harness_plugins:load_object") is hp.load_object
# module.attr form
assert native_dispatch.resolve("omnigent.harness_plugins.load_object") is hp.load_object
def test_resolve_is_cached() -> None:
first = native_dispatch.resolve("omnigent.harness_plugins:native_agents")
second = native_dispatch.resolve("omnigent.harness_plugins:native_agents")
assert first is second
assert "omnigent.harness_plugins:native_agents" in native_dispatch._RESOLVE_CACHE
def test_resolve_hook_returns_none_for_unset_optional_hook() -> None:
provider = hp.native_provider_for_key("claude")
assert provider is not None
# interrupt_handler is not yet a module-level function, so it stays None.
assert provider.interrupt_handler is None
assert native_dispatch.resolve_hook(provider, "interrupt_handler") is None
def test_resolve_hook_resolves_populated_hook() -> None:
provider = hp.native_provider_for_key("pi")
assert provider is not None
run_native = native_dispatch.resolve_hook(provider, "run_native")
assert callable(run_native)
assert run_native.__name__ == "run_pi_native"
def test_resolve_hook_for_key() -> None:
run_native = native_dispatch.resolve_hook_for_key("codex", "run_native")
assert callable(run_native)
assert run_native.__name__ == "run_codex_native"
def test_resolve_hook_for_unknown_key_returns_none() -> None:
assert native_dispatch.resolve_hook_for_key("does-not-exist", "run_native") is None
+31
View File
@@ -177,3 +177,34 @@ def test_terminal_attach_url_encodes_path_components_and_switches_scheme() -> No
url == "wss://example.databricks.com/base/v1/sessions/conv%2Fa%20b"
"/resources/terminals/terminal%2Fmain/attach"
)
def test_normalize_extra_args_prefers_extra_args() -> None:
assert native_terminal.normalize_extra_args(
extra_args=("--a",), legacy_args=None, legacy_param="pi_args"
) == ("--a",)
def test_normalize_extra_args_empty_when_neither_set() -> None:
assert (
native_terminal.normalize_extra_args(
extra_args=None, legacy_args=None, legacy_param="pi_args"
)
== ()
)
def test_normalize_extra_args_legacy_alias_warns_and_is_used() -> None:
with pytest.warns(DeprecationWarning, match="pi_args"):
result = native_terminal.normalize_extra_args(
extra_args=None, legacy_args=("--legacy",), legacy_param="pi_args"
)
assert result == ("--legacy",)
def test_normalize_extra_args_extra_args_wins_over_legacy_with_warning() -> None:
with pytest.warns(DeprecationWarning):
result = native_terminal.normalize_extra_args(
extra_args=("--new",), legacy_args=("--old",), legacy_param="pi_args"
)
assert result == ("--new",)
+7 -7
View File
@@ -121,7 +121,7 @@ def test_dispatch_by_runtime_claude_native_remote_routes_to_wrapper(
# Trailing slash stripped — the wrapper expects a bare base URL.
assert captured["server"] == "https://example.com"
# No leaking claude args; the wrapper builds its own.
assert captured["claude_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_codex_native_remote_routes_to_wrapper(
@@ -158,7 +158,7 @@ def test_dispatch_by_runtime_codex_native_remote_routes_to_wrapper(
assert captured["session_id"] == "4e92b5a0c0ee6db3f874f9c4a3f855a5"
assert captured["server"] == "https://example.com"
assert captured["codex_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_codex_native_local_routes_to_wrapper(
@@ -195,7 +195,7 @@ def test_dispatch_by_runtime_codex_native_local_routes_to_wrapper(
assert captured["session_id"] == "415c9954e2fe4b9276083a4d2c66f689"
assert captured["server"] is None
assert captured["codex_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_kiro_native_remote_routes_to_wrapper(
@@ -221,7 +221,7 @@ def test_dispatch_by_runtime_kiro_native_remote_routes_to_wrapper(
assert captured["session_id"] == "823dbd1aab969b5a813fac59bb977a77"
assert captured["server"] == "https://example.com"
assert captured["kiro_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_antigravity_native_remote_routes_to_wrapper(
@@ -262,7 +262,7 @@ def test_dispatch_by_runtime_antigravity_native_remote_routes_to_wrapper(
assert captured["session_id"] == "a8bcbee631c58ddb98fb5e3f54a1592a"
assert captured["server"] == "https://example.com"
assert captured["antigravity_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_antigravity_native_local_routes_to_wrapper(
@@ -299,7 +299,7 @@ def test_dispatch_by_runtime_antigravity_native_local_routes_to_wrapper(
assert captured["session_id"] == "e85224ee39457def1d20bcce5b74ed8c"
assert captured["server"] is None
assert captured["antigravity_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_claude_native_local_still_routes_to_wrapper(
@@ -336,7 +336,7 @@ def test_dispatch_by_runtime_claude_native_local_still_routes_to_wrapper(
assert captured["session_id"] == "64a784c3aa907d1774f44313546947c6"
assert captured["server"] is None
assert captured["claude_args"] == ()
assert captured["extra_args"] == ()
def test_dispatch_by_runtime_non_wrapper_local_raises_with_hint(