diff --git a/src/memu/hosts/claude_code/cli.py b/src/memu/hosts/claude_code/cli.py index 08d8a889..79a7637c 100644 --- a/src/memu/hosts/claude_code/cli.py +++ b/src/memu/hosts/claude_code/cli.py @@ -49,6 +49,7 @@ session_help="Claude Code session log (one project dir per escaped cwd)", instruction_path=CLAUDE_MD, skills_dir=SKILLS_DIR, + schedule_backend="os", schedule_command="claude -p {prompt}", session_id_env=SESSION_ID_ENV, needs_headless_auth=True, diff --git a/src/memu/hosts/codex/cli.py b/src/memu/hosts/codex/cli.py index bea39c73..257c169a 100644 --- a/src/memu/hosts/codex/cli.py +++ b/src/memu/hosts/codex/cli.py @@ -54,6 +54,7 @@ session_help="Codex session log", instruction_path=AGENTS_MD, skills_dir=SKILLS_DIR, + schedule_backend="native", ) diff --git a/src/memu/hosts/cola/cli.py b/src/memu/hosts/cola/cli.py index 04b5b383..13fc1a07 100644 --- a/src/memu/hosts/cola/cli.py +++ b/src/memu/hosts/cola/cli.py @@ -24,6 +24,7 @@ session_help="Cola JSONL session directory (one directory per session scope)", instruction_path=MEMORY_MD, skills_dir=SKILLS_DIR, + schedule_backend="native", ) diff --git a/src/memu/hosts/cursor/cli.py b/src/memu/hosts/cursor/cli.py index d678f4ef..07260b85 100644 --- a/src/memu/hosts/cursor/cli.py +++ b/src/memu/hosts/cursor/cli.py @@ -44,6 +44,7 @@ # the template so both the auth gate and the scheduled run carry it, and the # trust lands on the task's working tree (~/.memu/hosts/cursor), nowhere else. # Never --yolo — that is the blanket permission skip the guides reject. + schedule_backend="os", schedule_command="cursor-agent --trust -p {prompt}", # Cursor Agent 2026.08.04 injects its current conversation id into shell # tools under this name. Cursor names the transcript directory and JSONL diff --git a/src/memu/hosts/generic/cli.py b/src/memu/hosts/generic/cli.py index ddc0a009..97e4bd2a 100644 --- a/src/memu/hosts/generic/cli.py +++ b/src/memu/hosts/generic/cli.py @@ -74,6 +74,7 @@ def _register_detect(sub: Any) -> None: session_dir="", # no universal location — detect finds it, prepare requires it session_help="The agent's session-log directory (find it with `memu-agent detect`)", instruction_path=AGENTS_MD, + schedule_backend="external", register_extra=_register_detect, ) diff --git a/src/memu/hosts/hermes/cli.py b/src/memu/hosts/hermes/cli.py index 65ba0b93..63736580 100644 --- a/src/memu/hosts/hermes/cli.py +++ b/src/memu/hosts/hermes/cli.py @@ -56,6 +56,7 @@ session_dir=str(state_db_path()), session_help="Hermes SQLite session store (state.db under HERMES_HOME)", instruction_path=SOUL_MD, + schedule_backend="os", schedule_command="hermes -z {prompt}", schedule_prepare_session_dir=True, session_id_env=SESSION_ID_ENV, diff --git a/src/memu/hosts/host_cli.py b/src/memu/hosts/host_cli.py index f06206f8..b99cb62f 100644 --- a/src/memu/hosts/host_cli.py +++ b/src/memu/hosts/host_cli.py @@ -33,7 +33,7 @@ from dataclasses import dataclass, field from importlib.resources import files from pathlib import Path -from typing import Any +from typing import Any, Literal from memu import events from memu.hosts import instruction, retrieval, templates @@ -42,6 +42,9 @@ from memu.hosts.bridging.pipeline import MAX_JOBS from memu.hosts.bridging.resources import verify_resource_log +ScheduleBackend = Literal["os", "native", "external"] +"""The existing scheduler arrangement a host documents; informational only.""" + DOCS = {"install": "INSTALL.md", "task": "BRIDGING_TASK.md", "uninstall": "UNINSTALL.md"} @@ -87,14 +90,21 @@ class HostSpec: """memU working tree. Empty means the per-host default ``~/.memu/hosts/``; Codex overrides this with the pre-multi-host ``~/.memu`` it has always used.""" + schedule_backend: ScheduleBackend = "external" + """The host's documented scheduler arrangement. + + Informational metadata only: it never controls task registration, scheduler + invocation, or which CLI commands are available. + """ + schedule_command: str = "" - """The headless agent invocation the bridging task runs, as a template with a - ``{prompt}`` placeholder — ``claude -p {prompt}``, ``codex exec {prompt}``. The - Windows ``schedule`` helper turns this into the scheduled task's wrapper, and - treats the first token as the agent binary to resolve on ``PATH``. Empty means - the host has no Windows scheduling wired yet, so ``schedule`` refuses rather - than guess. Unix scheduling is unaffected — cron/launchd stay doc-driven and - never read this field.""" + """The headless agent invocation the Windows Task Scheduler helper runs, as a + template with a ``{prompt}`` placeholder — ``claude -p {prompt}``. The helper + turns this into the scheduled task's wrapper, treats the first token as the + agent binary to resolve on ``PATH``, and exposes the ``schedule`` subcommand + only when it is set. Empty means that helper is not wired for this host; Unix + scheduling remains doc-driven and never reads this field. + """ schedule_prepare_session_dir: bool = False """Bake this host's resolved ``session_dir`` into the scheduled PREPARE command. @@ -589,12 +599,12 @@ def _report_install_started(spec: HostSpec) -> None: async def _cmd_schedule(spec: HostSpec, args: argparse.Namespace) -> int: - """Register/inspect the bridging task on Windows Task Scheduler. + """Register/inspect the existing OS-scheduled bridging task on Windows Task Scheduler. - Only registered for hosts that set ``schedule_command`` (those that bridge via an - OS scheduler), so it never reaches a host that has its own. Windows-only by design - (memU#538/#539); on macOS/Linux it just points at the unchanged cron/launchd - registration in ``BRIDGING_TASK.md`` and touches neither. + The parser registers this handler only when ``schedule_command`` supplies an + invocation for the Windows helper. ``schedule_backend`` is informational and + does not affect this behavior. On macOS/Linux, this points at the existing + cron/launchd registration in ``BRIDGING_TASK.md`` and touches neither. """ system = platform.system() if system != "Windows": @@ -775,10 +785,9 @@ async def bound(args: argparse.Namespace) -> int: ) p.set_defaults(handler=bind(_cmd_docs)) - # Windows-only automation of the bridging task's registration — registered only - # for hosts that bridge via an OS scheduler, which is exactly the ones that set - # `schedule_command`. Hosts with their own scheduler (Codex, OpenClaw, WorkBuddy) - # never set it, so they never advertise a `schedule` verb they couldn't honour. + # Windows-only automation of the bridging task's registration. The invocation + # template, not the informational scheduler label, is the capability gate: hosts + # without a Windows wrapper never advertise a `schedule` verb they cannot honour. if spec.schedule_command: p = with_base( sub.add_parser("schedule", help=f"Register the {spec.display} bridging task (Windows Task Scheduler)") diff --git a/src/memu/hosts/openclaw/cli.py b/src/memu/hosts/openclaw/cli.py index 6dfb0e90..53b1c68b 100644 --- a/src/memu/hosts/openclaw/cli.py +++ b/src/memu/hosts/openclaw/cli.py @@ -43,6 +43,7 @@ session_help="OpenClaw agents dir holding each agent's transcript store (SQLite, or legacy sessions/*.jsonl)", instruction_path=AGENTS_MD, skills_dir=SKILLS_DIR, + schedule_backend="native", ) diff --git a/src/memu/hosts/workbuddy/cli.py b/src/memu/hosts/workbuddy/cli.py index 2878bfa2..ce101b38 100644 --- a/src/memu/hosts/workbuddy/cli.py +++ b/src/memu/hosts/workbuddy/cli.py @@ -42,6 +42,7 @@ session_help="WorkBuddy session log (one project dir per escaped cwd)", instruction_path=SOUL_MD, legacy_instruction_paths=(MEMORY_MD,), + schedule_backend="native", ) diff --git a/tests/test_scheduling_windows.py b/tests/test_scheduling_windows.py index 129c9e65..53be19b6 100644 --- a/tests/test_scheduling_windows.py +++ b/tests/test_scheduling_windows.py @@ -23,10 +23,14 @@ from memu.hosts.bridging import Layout from memu.hosts.claude_code.cli import SPEC as CLAUDE from memu.hosts.codex.cli import SPEC as CODEX +from memu.hosts.cola.cli import SPEC as COLA from memu.hosts.cursor.cli import SPEC as CURSOR +from memu.hosts.generic.cli import SPEC as GENERIC from memu.hosts.hermes.cli import SPEC as HERMES -from memu.hosts.host_cli import build_parser, run +from memu.hosts.host_cli import ScheduleBackend, build_parser, run +from memu.hosts.openclaw.cli import SPEC as OPENCLAW from memu.hosts.scheduling import prompt, windows +from memu.hosts.workbuddy.cli import SPEC as WORKBUDDY # --------------------------------------------------------------------------- # Pure builders @@ -160,15 +164,47 @@ def test_schedule_verb_is_wired() -> None: parser.parse_args(["schedule", "frobnicate"]) +def test_schedule_backends_describe_existing_host_arrangements() -> None: + assert { + spec.host: spec.schedule_backend for spec in (CLAUDE, CURSOR, HERMES, CODEX, OPENCLAW, WORKBUDDY, COLA, GENERIC) + } == { + "claude-code": "os", + "cursor": "os", + "hermes": "os", + "codex": "native", + "openclaw": "native", + "workbuddy": "native", + "cola": "native", + "agent": "external", + } + + +@pytest.mark.parametrize("backend", ("os", "native", "external")) +def test_schedule_backend_does_not_control_verb_wiring(backend: ScheduleBackend) -> None: + assert ( + build_parser(dataclasses.replace(CLAUDE, schedule_backend=backend)).parse_args(["schedule", "status"]).action + == "status" + ) + with pytest.raises(SystemExit): + build_parser(dataclasses.replace(CLAUDE, schedule_command="", schedule_backend=backend)).parse_args([ + "schedule", + "status", + ]) + + def test_unwired_host_has_no_schedule_verb() -> None: - # A host with no schedule_command — e.g. one that uses its own scheduler (Codex, - # OpenClaw, WorkBuddy) — must not advertise a `schedule` verb at all, not even a + # Native and external hosts never advertise a `schedule` verb, not even a # refusing stub. argparse rejects it as an unknown command. + for spec in (CODEX, OPENCLAW, WORKBUDDY, COLA, GENERIC): + with pytest.raises(SystemExit): + build_parser(spec).parse_args(["schedule", "status"]) + # ...while a wired host does have it. + assert build_parser(CLAUDE).parse_args(["schedule", "status"]).action == "status" + + # An OS invocation remains the capability gate, independent of its label. unwired = dataclasses.replace(CLAUDE, schedule_command="") with pytest.raises(SystemExit): build_parser(unwired).parse_args(["schedule", "status"]) - # ...while a wired host does have it. - assert build_parser(CLAUDE).parse_args(["schedule", "status"]).action == "status" def test_schedule_points_at_cron_off_windows(