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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/memu/hosts/claude_code/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/codex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
session_help="Codex session log",
instruction_path=AGENTS_MD,
skills_dir=SKILLS_DIR,
schedule_backend="native",
)


Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/cola/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/cursor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/generic/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/hermes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 26 additions & 17 deletions src/memu/hosts/host_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}


Expand Down Expand Up @@ -87,14 +90,21 @@ class HostSpec:
"""memU working tree. Empty means the per-host default ``~/.memu/hosts/<host>``;
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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)")
Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/openclaw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
1 change: 1 addition & 0 deletions src/memu/hosts/workbuddy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
46 changes: 41 additions & 5 deletions tests/test_scheduling_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down