opencommand 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencommand/__init__.py +4 -0
- opencommand/cli.py +375 -0
- opencommand/core/agent.py +91 -0
- opencommand/core/config.py +65 -0
- opencommand/core/errors.py +23 -0
- opencommand/core/logging.py +65 -0
- opencommand/core/supervisor.py +81 -0
- opencommand/core/tools.py +136 -0
- opencommand/engine/__init__.py +66 -0
- opencommand/engine/runner.py +275 -0
- opencommand/modules/knowledge.py +85 -0
- opencommand/modules/memory/MEMORY.md +14 -0
- opencommand/modules/skills/bash.md +14 -0
- opencommand/modules/skills/powershell.md +15 -0
- opencommand/modules/skills/python.md +20 -0
- opencommand/modules/skills/system.md +38 -0
- opencommand/modules/system/automatic.py +151 -0
- opencommand/modules/system/cron.py +193 -0
- opencommand/modules/system/notify.py +85 -0
- opencommand/modules/system/platform.py +197 -0
- opencommand/modules/templates.py +227 -0
- opencommand/modules/tools/desktop.py +127 -0
- opencommand/modules/tools/files.py +82 -0
- opencommand/modules/tools/instructions.py +163 -0
- opencommand/modules/tools/memory.py +78 -0
- opencommand/modules/tools/playwright.py +61 -0
- opencommand/modules/tools/research.py +46 -0
- opencommand/modules/tools/system.py +163 -0
- opencommand/modules/tools/terminal.py +53 -0
- opencommand/providers/provider.py +157 -0
- opencommand/swarm/agents/commander.py +530 -0
- opencommand/swarm/agents/worker.py +26 -0
- opencommand/tui/dashboard.py +41 -0
- opencommand-0.1.0.dist-info/METADATA +76 -0
- opencommand-0.1.0.dist-info/RECORD +37 -0
- opencommand-0.1.0.dist-info/WHEEL +4 -0
- opencommand-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# PowerShell Skill
|
|
2
|
+
|
|
3
|
+
Use `powershell` for Windows automation. OpenCommand's `terminal` tool runs
|
|
4
|
+
`cmd.exe /c` on Windows; for PowerShell-specific cmdlets, wrap with
|
|
5
|
+
`powershell -NoProfile -Command "..."`.
|
|
6
|
+
|
|
7
|
+
## Conventions
|
|
8
|
+
- Use `;` to chain and `&&` (PowerShell 7+) for conditional chains.
|
|
9
|
+
- Prefer `Get-ChildItem`, `Get-Content`, `Select-String` over `dir`/`type`.
|
|
10
|
+
- Manage Python via `uv` (Windows-supported): `uv add`, `uv run`.
|
|
11
|
+
- Services: `Get-Process`, `Start-Process -NoNewWindow`.
|
|
12
|
+
- Paths use backslashes or forward slashes; quote paths with spaces.
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
- Windows-only tooling, registry, WSL interop, native Windows builds.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python Skill
|
|
2
|
+
|
|
3
|
+
OpenCommand targets **Python 3.14** (free-threaded build) and uses **`uv`** for
|
|
4
|
+
all Python environment and task management.
|
|
5
|
+
|
|
6
|
+
## Conventions
|
|
7
|
+
- Use `uv` everywhere: `uv add <pkg>`, `uv run <module>`, `uv sync`, `uv build`.
|
|
8
|
+
- Target Python >= 3.14; leverage free-threading for parallel worker I/O.
|
|
9
|
+
- Prefer `asyncio` + `TaskGroup` for concurrency; `subprocess` (not `os.popen`,
|
|
10
|
+
which is soft-deprecated in 3.14) for shelling out.
|
|
11
|
+
- Type hints required; `from __future__ import annotations` is fine (3.14
|
|
12
|
+
defers annotation evaluation).
|
|
13
|
+
- Use `pathlib.Path`, `logging`, and `dataclasses` over manual boilerplate.
|
|
14
|
+
|
|
15
|
+
## Testing
|
|
16
|
+
- `uv run pytest` for unit tests; `uv run pytest -q` in CI.
|
|
17
|
+
- For interactive/UI testing, use the `desktop` tool (vision + pyautogui).
|
|
18
|
+
|
|
19
|
+
## When to use
|
|
20
|
+
- Implementing agents, tools, providers, and the CLI/TUI in OpenCommand.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# System Skill
|
|
2
|
+
|
|
3
|
+
OpenCommand's `system` tool gives the swarm **situational awareness** and
|
|
4
|
+
**safety introspection** over the host machine. Use it for monitoring, scanning,
|
|
5
|
+
and scheduled automation.
|
|
6
|
+
|
|
7
|
+
## `system` tool actions
|
|
8
|
+
- `list_processes` — snapshot of running processes (pid/name/cpu/mem). Use to
|
|
9
|
+
spot runaway agents, duplicate loops, or suspicious apps before/after actions.
|
|
10
|
+
- `scan_file <path> <rules>` — run YARA rules over a single file. `rules` is a
|
|
11
|
+
path to a `.yar` file or inline YARA rule text.
|
|
12
|
+
- `scan_directory <path> <rules>` — recursive YARA scan of a directory
|
|
13
|
+
(e.g. `Downloads`, build artifacts). `recursive` defaults to true.
|
|
14
|
+
- `watch <path> [seconds=10]` — use `watchdog` to report filesystem changes in
|
|
15
|
+
a directory (catches new downloads, temp files, agent writes).
|
|
16
|
+
|
|
17
|
+
> YARA is invoked via the `yara` CLI when present on PATH. If the binary is
|
|
18
|
+
> missing the tool reports it clearly instead of crashing. Install YARA
|
|
19
|
+
> (https://github.com/VirusTotal/yara) to enable scanning.
|
|
20
|
+
|
|
21
|
+
## Cron utility (`modules/system/cron.py`)
|
|
22
|
+
Schedule recurring tasks without external services. A `CronScheduler` runs jobs
|
|
23
|
+
in the background (asyncio).
|
|
24
|
+
|
|
25
|
+
Schedule syntax:
|
|
26
|
+
- `every 30m` / `every 1h` — fixed interval
|
|
27
|
+
- `hourly :15` — every hour at minute 15
|
|
28
|
+
- `daily 09:00` — every day at 09:00 local time
|
|
29
|
+
- `*/5 * * * *` — full 5-field cron (requires optional `croniter`)
|
|
30
|
+
|
|
31
|
+
CLI:
|
|
32
|
+
- `opencommand cron add <name> <schedule> "<command>"` — register a shell job
|
|
33
|
+
- `opencommand cron status` — show scheduler info
|
|
34
|
+
|
|
35
|
+
## When to use
|
|
36
|
+
- Pre-flight safety checks (list processes, scan a downloaded binary).
|
|
37
|
+
- Watching `Downloads` / build dirs for changes during long autonomous runs.
|
|
38
|
+
- Scheduling maintenance (e.g. `daily 03:00` cleanup, `every 1h` health ping).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Automatic mode: the long-running, self-correcting loop.
|
|
2
|
+
|
|
3
|
+
Runs the Commander's pipeline for a goal, then verifies the result with a real
|
|
4
|
+
test command (exit-code gate, not string matching). On failure it re-dispatches
|
|
5
|
+
a focused fixer with the failing report as context, preserving the original goal
|
|
6
|
+
so the Commander never re-plans from a fix prompt. Loops until tests pass or
|
|
7
|
+
max iterations is hit. This is the main mode for large codebases and hours-long
|
|
8
|
+
autonomous runs.
|
|
9
|
+
|
|
10
|
+
Phase 6 adds two gates on top of the exit-code check:
|
|
11
|
+
* a Commander review gate (evaluator-optimizer) that can reject work even when
|
|
12
|
+
tests pass (wrong thing built, requirements missing);
|
|
13
|
+
* an optional live-verify hook (the `desktop` tool) for interactive validation
|
|
14
|
+
when the goal is UI/UX facing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from ...core.logging import get_logger
|
|
23
|
+
from ...swarm.agents.commander import Commander
|
|
24
|
+
from .notify import default_notifier
|
|
25
|
+
|
|
26
|
+
log = get_logger("system.automatic")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def run_automatic(commander: Commander, goal: str, max_iterations: int = 5,
|
|
30
|
+
verify_cmd: str | None = None, pipeline: str = "standard",
|
|
31
|
+
live_verify: bool = False) -> str:
|
|
32
|
+
"""Iteratively execute the goal, auto-fixing failures, until 100% met.
|
|
33
|
+
|
|
34
|
+
``pipeline`` is "standard" or "advanced" (scaffold + research loop +
|
|
35
|
+
commander review gate + test/debug loop). ``live_verify`` runs the desktop
|
|
36
|
+
tool as a final interactive check when the goal is UI/UX facing.
|
|
37
|
+
|
|
38
|
+
The loop only returns success when BOTH the exit-code verify gate AND the
|
|
39
|
+
Commander review gate pass (and, if enabled, the live-verify check). If the
|
|
40
|
+
Commander flags the work as off-track, the whole pipeline is re-run with the
|
|
41
|
+
failing report as fix context so the swarm re-grounds on the goal.
|
|
42
|
+
"""
|
|
43
|
+
original_goal = goal
|
|
44
|
+
if default_notifier.enabled:
|
|
45
|
+
default_notifier.send(f"Started autonomous run for: {goal[:120]}",
|
|
46
|
+
title="OpenCommand: started", priority=3,
|
|
47
|
+
tags=["rocket", "opencommand"])
|
|
48
|
+
last = ""
|
|
49
|
+
for i in range(max_iterations):
|
|
50
|
+
log.info("iteration=%d/%d goal=%r pipeline=%s", i + 1, max_iterations,
|
|
51
|
+
original_goal, pipeline)
|
|
52
|
+
last = await _execute(commander, original_goal, pipeline,
|
|
53
|
+
verify_cmd=verify_cmd, live_verify=live_verify)
|
|
54
|
+
# Exit-code gate (real verification, not string matching).
|
|
55
|
+
if verify_cmd:
|
|
56
|
+
rc, out = _verify(commander, verify_cmd)
|
|
57
|
+
if rc != 0:
|
|
58
|
+
last = (f"{last}\n\n[verify] {verify_cmd} -> FAIL (exit {rc})\n{out}")
|
|
59
|
+
log.warning("verify FAIL exit=%d iteration=%d", rc, i + 1)
|
|
60
|
+
else:
|
|
61
|
+
# Tests pass: now the Commander review gate (evaluator-optimizer).
|
|
62
|
+
review = await commander.review_goal(original_goal, last)
|
|
63
|
+
if review.passed and not review.off_track:
|
|
64
|
+
if live_verify:
|
|
65
|
+
lv = await _live_verify(commander, goal)
|
|
66
|
+
if not lv.ok:
|
|
67
|
+
last = (f"{last}\n\n[live-verify] FAIL: {lv.summary}\n"
|
|
68
|
+
f"{lv.detail}")
|
|
69
|
+
goal = _fix_prompt(original_goal, last)
|
|
70
|
+
continue
|
|
71
|
+
last = f"{last}\n\n[live-verify] PASS: {lv.summary}"
|
|
72
|
+
if default_notifier.enabled:
|
|
73
|
+
default_notifier.send(f"Goal complete after {i + 1} iteration(s).",
|
|
74
|
+
title="OpenCommand: success", priority=4,
|
|
75
|
+
tags=["white_check_mark", "opencommand"])
|
|
76
|
+
log.info("goal complete after %d iteration(s)", i + 1)
|
|
77
|
+
return f"{last}\n\n[verify] {verify_cmd} -> PASS (exit 0)"
|
|
78
|
+
# Tests pass but Commander says wrong thing built / off track.
|
|
79
|
+
reason = "OFF TRACK" if review.off_track else "REJECTED"
|
|
80
|
+
last = (f"{last}\n\n[review] {reason}: {review.feedback}\n"
|
|
81
|
+
f"missing: {', '.join(review.missing)}")
|
|
82
|
+
log.warning("commander %s iteration=%d missing=%s", reason.lower(),
|
|
83
|
+
i + 1, review.missing)
|
|
84
|
+
else:
|
|
85
|
+
# No verification command: fall back to the old string heuristic.
|
|
86
|
+
if "FAILED" not in last and "ERROR" not in last:
|
|
87
|
+
if default_notifier.enabled:
|
|
88
|
+
default_notifier.send(f"Goal complete after {i + 1} iteration(s).",
|
|
89
|
+
title="OpenCommand: success", priority=4,
|
|
90
|
+
tags=["white_check_mark", "opencommand"])
|
|
91
|
+
return last
|
|
92
|
+
goal = _fix_prompt(original_goal, last)
|
|
93
|
+
if default_notifier.enabled:
|
|
94
|
+
default_notifier.send(f"Stopped after {max_iterations} iterations (not resolved).",
|
|
95
|
+
title="OpenCommand: stopped", priority=5,
|
|
96
|
+
tags=["warning", "opencommand"])
|
|
97
|
+
return f"(automatic mode stopped after {max_iterations} iterations)\n{last}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _execute(commander: Commander, goal: str, pipeline: str,
|
|
101
|
+
verify_cmd: str | None = None, live_verify: bool = False) -> str:
|
|
102
|
+
if pipeline == "advanced":
|
|
103
|
+
return await commander.execute_advanced(
|
|
104
|
+
goal, verify_cmd=verify_cmd, live_verify=live_verify)
|
|
105
|
+
return await commander.execute(goal)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fix_prompt(original_goal: str, last: str) -> str:
|
|
109
|
+
return (f"Previous attempt failed. The original goal is still:\n{original_goal}\n\n"
|
|
110
|
+
f"Fix the issues shown below, then re-run the verification command.\n\n{last}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _live_verify(commander: Commander, goal: str) -> "LiveVerifyResult":
|
|
114
|
+
"""Interactive validation via the desktop tool (vision + action loop).
|
|
115
|
+
|
|
116
|
+
Returns a LiveVerifyResult. Only meaningful when the desktop tool is wired
|
|
117
|
+
and vision is enabled; otherwise reports a no-op pass so the pipeline can
|
|
118
|
+
continue without a UI target.
|
|
119
|
+
"""
|
|
120
|
+
from ...modules.tools.desktop import DesktopTool
|
|
121
|
+
|
|
122
|
+
desktop = next((t for t in commander.tools._tools.values()
|
|
123
|
+
if isinstance(t, DesktopTool)), None)
|
|
124
|
+
if desktop is None or not getattr(desktop, "vision", None):
|
|
125
|
+
return LiveVerifyResult(ok=True, summary="no desktop tool/vision; skipped",
|
|
126
|
+
detail="")
|
|
127
|
+
try:
|
|
128
|
+
out = desktop.run(f"Verify the goal is satisfied on screen: {goal}", steps=8)
|
|
129
|
+
except Exception as e: # noqa: BLE001
|
|
130
|
+
return LiveVerifyResult(ok=False, summary="desktop tool error",
|
|
131
|
+
detail=str(e))
|
|
132
|
+
ok = "ERROR" not in out and "done" in out.lower()
|
|
133
|
+
return LiveVerifyResult(ok=ok, summary="interactive check completed", detail=out)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class LiveVerifyResult:
|
|
138
|
+
ok: bool = False
|
|
139
|
+
summary: str = ""
|
|
140
|
+
detail: str = ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _verify(commander: Commander, cmd: str) -> tuple[int, str]:
|
|
144
|
+
"""Run the verification command in the workspace; return (rc, output)."""
|
|
145
|
+
try:
|
|
146
|
+
proc = subprocess.run(cmd, shell=True, cwd=str(commander.workspace),
|
|
147
|
+
capture_output=True, text=True, timeout=600)
|
|
148
|
+
except subprocess.TimeoutExpired as e:
|
|
149
|
+
return 124, f"verify timed out: {e}"
|
|
150
|
+
out = f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"[:4000]
|
|
151
|
+
return proc.returncode, out
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Lightweight cron utility for OpenCommand.
|
|
2
|
+
|
|
3
|
+
Schedules tasks at configurable times without external services. A single
|
|
4
|
+
CronScheduler runs in the background (asyncio) and fires jobs when their
|
|
5
|
+
schedule matches. Supported syntax:
|
|
6
|
+
- "every 30m" / "every 1h" fixed interval
|
|
7
|
+
- "hourly :15" every hour at minute 15
|
|
8
|
+
- "daily 09:00" every day at 09:00 local time
|
|
9
|
+
- "*/5 * * * *" 5-field cron (requires optional croniter)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Awaitable, Callable
|
|
21
|
+
|
|
22
|
+
JobFn = Callable[[], Awaitable[None] | None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CronJob:
|
|
27
|
+
name: str
|
|
28
|
+
schedule: str
|
|
29
|
+
fn: JobFn
|
|
30
|
+
command: str = "" # shell command (serializable)
|
|
31
|
+
goal: str = "" # swarm goal (serializable); takes precedence over command
|
|
32
|
+
last_run: datetime | None = None
|
|
33
|
+
_interval: timedelta | None = field(default=None, repr=False)
|
|
34
|
+
_daily_at: tuple[int, int] | None = field(default=None, repr=False)
|
|
35
|
+
_croniter = None # optional croniter object
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
self._parse()
|
|
39
|
+
|
|
40
|
+
def _parse(self) -> None:
|
|
41
|
+
s = self.schedule.strip().lower()
|
|
42
|
+
m = re.fullmatch(r"every\s+(\d+)\s*([mh])", s)
|
|
43
|
+
if m:
|
|
44
|
+
n = int(m.group(1))
|
|
45
|
+
self._interval = timedelta(minutes=n) if m.group(2) == "m" else timedelta(hours=n)
|
|
46
|
+
return
|
|
47
|
+
m = re.fullmatch(r"hourly\s*:(\d{1,2})", s)
|
|
48
|
+
if m:
|
|
49
|
+
self._daily_at = (0, int(m.group(1))) # sentinel: hourly at minute
|
|
50
|
+
return
|
|
51
|
+
m = re.fullmatch(r"daily\s*(\d{1,2}):(\d{2})", s)
|
|
52
|
+
if m:
|
|
53
|
+
self._daily_at = (int(m.group(1)), int(m.group(2)))
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
from croniter import croniter # type: ignore
|
|
57
|
+
self._croniter = croniter(s, datetime.now())
|
|
58
|
+
except Exception:
|
|
59
|
+
raise ValueError(f"Unsupported cron schedule: {self.schedule!r}")
|
|
60
|
+
|
|
61
|
+
def next_run(self, now: datetime) -> datetime:
|
|
62
|
+
if self._interval is not None:
|
|
63
|
+
return (self.last_run or now) + self._interval
|
|
64
|
+
if self._daily_at is not None:
|
|
65
|
+
hh, mm = self._daily_at
|
|
66
|
+
if hh == 0: # hourly at minute mm
|
|
67
|
+
nxt = now.replace(minute=mm, second=0, microsecond=0)
|
|
68
|
+
if nxt <= now:
|
|
69
|
+
nxt += timedelta(hours=1)
|
|
70
|
+
return nxt
|
|
71
|
+
nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
|
72
|
+
if nxt <= now:
|
|
73
|
+
nxt += timedelta(days=1)
|
|
74
|
+
return nxt
|
|
75
|
+
if self._croniter is not None:
|
|
76
|
+
# get_next() advances the iterator, so compute a non-advancing preview
|
|
77
|
+
# for status/display; the loop uses _next_fire() which advances once.
|
|
78
|
+
return self._croniter.get_next(datetime, start_time=now)
|
|
79
|
+
raise ValueError("no schedule parsed")
|
|
80
|
+
|
|
81
|
+
def _next_fire(self, now: datetime) -> datetime:
|
|
82
|
+
"""Advance the croniter to the next fire time (mutates the iterator)."""
|
|
83
|
+
if self._croniter is not None:
|
|
84
|
+
return self._croniter.get_next(datetime)
|
|
85
|
+
return self.next_run(now)
|
|
86
|
+
|
|
87
|
+
async def _call(self) -> None:
|
|
88
|
+
result = self.fn()
|
|
89
|
+
if asyncio.iscoroutine(result):
|
|
90
|
+
await result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CronScheduler:
|
|
94
|
+
"""Runs registered jobs in the background until stopped.
|
|
95
|
+
|
|
96
|
+
Persisted jobs are either shell ``command``s or swarm ``goal``s. A goal job
|
|
97
|
+
is dispatched through a callback (set via ``on_goal``) so the scheduler can
|
|
98
|
+
drive the real Commander/Worker loop instead of just a subprocess.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, tick: float = 15.0) -> None:
|
|
102
|
+
self._jobs: list[CronJob] = []
|
|
103
|
+
self._tick = tick
|
|
104
|
+
self._task: asyncio.Task | None = None
|
|
105
|
+
self._stop = asyncio.Event()
|
|
106
|
+
self.on_goal: Callable[[str], Awaitable[None]] | None = None
|
|
107
|
+
|
|
108
|
+
def add(self, name: str, schedule: str, fn: JobFn) -> CronJob:
|
|
109
|
+
job = CronJob(name=name, schedule=schedule, fn=fn)
|
|
110
|
+
self._jobs.append(job)
|
|
111
|
+
return job
|
|
112
|
+
|
|
113
|
+
def schedule_command(self, name: str, schedule: str, command: str) -> CronJob:
|
|
114
|
+
"""Schedule a shell command (runs via subprocess)."""
|
|
115
|
+
async def _run() -> None:
|
|
116
|
+
proc = await asyncio.create_subprocess_shell(
|
|
117
|
+
command, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
|
118
|
+
await proc.wait()
|
|
119
|
+
job = self.add(name, schedule, _run)
|
|
120
|
+
job.command = command
|
|
121
|
+
return job
|
|
122
|
+
|
|
123
|
+
def schedule_goal(self, name: str, schedule: str, goal: str) -> CronJob:
|
|
124
|
+
"""Schedule a swarm goal; dispatched through ``on_goal`` when fired."""
|
|
125
|
+
async def _run() -> None:
|
|
126
|
+
if self.on_goal is not None:
|
|
127
|
+
res = self.on_goal(goal)
|
|
128
|
+
if asyncio.iscoroutine(res):
|
|
129
|
+
await res
|
|
130
|
+
job = self.add(name, schedule, _run)
|
|
131
|
+
job.goal = goal
|
|
132
|
+
return job
|
|
133
|
+
|
|
134
|
+
def save(self, path: Path) -> None:
|
|
135
|
+
"""Persist serializable jobs to <workspace>/.opencommand/cron.json."""
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
data = [{"name": j.name, "schedule": j.schedule,
|
|
138
|
+
"command": j.command, "goal": j.goal}
|
|
139
|
+
for j in self._jobs if j.command or j.goal]
|
|
140
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def load(cls, path: Path) -> "CronScheduler":
|
|
144
|
+
"""Load persisted jobs from cron.json (if present)."""
|
|
145
|
+
sched = cls()
|
|
146
|
+
if not path.exists():
|
|
147
|
+
return sched
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
150
|
+
except Exception:
|
|
151
|
+
return sched
|
|
152
|
+
for item in data:
|
|
153
|
+
if item.get("goal"):
|
|
154
|
+
sched.schedule_goal(item["name"], item["schedule"], item["goal"])
|
|
155
|
+
else:
|
|
156
|
+
sched.schedule_command(item["name"], item["schedule"], item.get("command", ""))
|
|
157
|
+
return sched
|
|
158
|
+
|
|
159
|
+
def start(self) -> None:
|
|
160
|
+
if self._task is None or self._task.done():
|
|
161
|
+
self._stop.clear()
|
|
162
|
+
self._task = asyncio.create_task(self._loop())
|
|
163
|
+
|
|
164
|
+
async def _loop(self) -> None:
|
|
165
|
+
while not self._stop.is_set():
|
|
166
|
+
now = datetime.now()
|
|
167
|
+
for job in self._jobs:
|
|
168
|
+
try:
|
|
169
|
+
if now >= job._next_fire(now):
|
|
170
|
+
job.last_run = now
|
|
171
|
+
await job._call()
|
|
172
|
+
except Exception: # noqa: BLE001 - a bad job shouldn't kill the loop
|
|
173
|
+
pass
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.wait_for(self._stop.wait(), timeout=self._tick)
|
|
176
|
+
except asyncio.TimeoutError:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
def stop(self) -> None:
|
|
180
|
+
self._stop.set()
|
|
181
|
+
|
|
182
|
+
def status(self) -> str:
|
|
183
|
+
now = datetime.now()
|
|
184
|
+
lines = [f"Cron jobs ({len(self._jobs)}):"]
|
|
185
|
+
for j in self._jobs:
|
|
186
|
+
try:
|
|
187
|
+
nxt = j.next_run(now).strftime("%Y-%m-%d %H:%M:%S")
|
|
188
|
+
except Exception:
|
|
189
|
+
nxt = "?"
|
|
190
|
+
last = j.last_run.strftime("%Y-%m-%d %H:%M:%S") if j.last_run else "never"
|
|
191
|
+
kind = "goal" if j.goal else ("cmd" if j.command else "fn")
|
|
192
|
+
lines.append(f" - {j.name} [{j.schedule}] ({kind}) next={nxt} last={last}")
|
|
193
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""ntfy push notifications for OpenCommand.
|
|
2
|
+
|
|
3
|
+
Lets the swarm push status / completion / failure alerts to an ntfy topic so
|
|
4
|
+
long-running autonomous runs can be monitored from a phone or another machine.
|
|
5
|
+
Uses the ntfy package with a requests POST fallback. Configure via env vars
|
|
6
|
+
(no secrets in code):
|
|
7
|
+
NTFY_TOPIC e.g. https://ntfy.sh/my-secret-topic (required to send)
|
|
8
|
+
NTFY_TOKEN optional bearer token for private servers
|
|
9
|
+
NTFY_PRIORITY optional 1..5 (default 3)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from ntfy import notify # type: ignore
|
|
19
|
+
_HAVE_NTFY = True
|
|
20
|
+
except Exception: # pragma: no cover - ntfy is a hard dep but keep import soft
|
|
21
|
+
notify = None # type: ignore
|
|
22
|
+
_HAVE_NTFY = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Notifier:
|
|
27
|
+
"""Thin wrapper around ntfy with env-var configuration."""
|
|
28
|
+
|
|
29
|
+
topic: str | None = None
|
|
30
|
+
token: str | None = None
|
|
31
|
+
priority: int = 3
|
|
32
|
+
default_tags: list[str] = field(default_factory=lambda: ["robot"])
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
self.topic = self.topic or os.environ.get("NTFY_TOPIC")
|
|
36
|
+
self.token = self.token or os.environ.get("NTFY_TOKEN")
|
|
37
|
+
if os.environ.get("NTFY_PRIORITY"):
|
|
38
|
+
try:
|
|
39
|
+
self.priority = int(os.environ["NTFY_PRIORITY"])
|
|
40
|
+
except ValueError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def enabled(self) -> bool:
|
|
45
|
+
return bool(self.topic)
|
|
46
|
+
|
|
47
|
+
def send(self, message: str, *, title: str | None = None,
|
|
48
|
+
priority: int | None = None, tags: list[str] | None = None) -> bool:
|
|
49
|
+
"""Send a notification. Returns True if sent, False if disabled/unavailable."""
|
|
50
|
+
if not self.topic:
|
|
51
|
+
return False
|
|
52
|
+
prio = priority or self.priority
|
|
53
|
+
all_tags = (tags or []) + self.default_tags
|
|
54
|
+
if _HAVE_NTFY and notify is not None:
|
|
55
|
+
try:
|
|
56
|
+
notify(message=message, title=title or "OpenCommand", topic=self.topic,
|
|
57
|
+
priority=prio, tags=all_tags, auth=self.token)
|
|
58
|
+
return True
|
|
59
|
+
except Exception:
|
|
60
|
+
pass # fall through to requests fallback
|
|
61
|
+
return self._send_requests(message, title=title, priority=prio, tags=all_tags)
|
|
62
|
+
|
|
63
|
+
def _send_requests(self, message: str, *, title: str | None,
|
|
64
|
+
priority: int, tags: list[str]) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
import requests
|
|
67
|
+
except Exception:
|
|
68
|
+
return False
|
|
69
|
+
headers = {
|
|
70
|
+
"Title": (title or "OpenCommand").replace("\n", " "),
|
|
71
|
+
"Priority": str(priority),
|
|
72
|
+
"Tags": ",".join(tags),
|
|
73
|
+
}
|
|
74
|
+
if self.token:
|
|
75
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
76
|
+
try:
|
|
77
|
+
resp = requests.post(self.topic, data=message.encode("utf-8"),
|
|
78
|
+
headers=headers, timeout=15)
|
|
79
|
+
return resp.ok
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Module-level default notifier (configured from env). Import and call .send().
|
|
85
|
+
default_notifier = Notifier()
|