bone-agent 1.3.3 → 2.0.0

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.
Files changed (121) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -184
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -141
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -36
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/targeted_searching.md +0 -10
  25. package/prompts/main/task_lists_pattern.md +0 -8
  26. package/prompts/main/temp_folder.md +0 -9
  27. package/prompts/main/think_before_acting.md +0 -10
  28. package/prompts/main/tone_and_style.md +0 -4
  29. package/prompts/main/tool_preferences.md +0 -24
  30. package/prompts/main/trust_subagent_context.md +0 -21
  31. package/prompts/main/when_to_use_sub_agent.md +0 -7
  32. package/prompts/micro/ask_questions.md +0 -1
  33. package/prompts/micro/batch_independent_calls.md +0 -1
  34. package/prompts/micro/casual_interactions.md +0 -1
  35. package/prompts/micro/code_references.md +0 -1
  36. package/prompts/micro/communication_style.md +0 -1
  37. package/prompts/micro/context_reliability.md +0 -1
  38. package/prompts/micro/conversational_tool_calling.md +0 -1
  39. package/prompts/micro/editing_pattern.md +0 -1
  40. package/prompts/micro/error_handling.md +0 -1
  41. package/prompts/micro/exploration_pattern.md +0 -1
  42. package/prompts/micro/intro.md +0 -1
  43. package/prompts/micro/obsidian.md +0 -4
  44. package/prompts/micro/obsidian_project.md +0 -5
  45. package/prompts/micro/professional_objectivity.md +0 -1
  46. package/prompts/micro/targeted_searching.md +0 -1
  47. package/prompts/micro/task_lists_pattern.md +0 -1
  48. package/prompts/micro/temp_folder.md +0 -1
  49. package/prompts/micro/think_before_acting.md +0 -5
  50. package/prompts/micro/tone_and_style.md +0 -1
  51. package/prompts/micro/tool_preferences.md +0 -1
  52. package/prompts/micro/trust_subagent_context.md +0 -1
  53. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  54. package/requirements.txt +0 -9
  55. package/src/__init__.py +0 -11
  56. package/src/core/__init__.py +0 -1
  57. package/src/core/agentic.py +0 -985
  58. package/src/core/chat_manager.py +0 -1564
  59. package/src/core/config_manager.py +0 -253
  60. package/src/core/cron.py +0 -582
  61. package/src/core/cron_allowlist.py +0 -118
  62. package/src/core/memory.py +0 -145
  63. package/src/core/retry.py +0 -71
  64. package/src/core/sub_agent.py +0 -326
  65. package/src/core/tool_approval.py +0 -220
  66. package/src/core/tool_feedback.py +0 -778
  67. package/src/exceptions.py +0 -79
  68. package/src/llm/__init__.py +0 -1
  69. package/src/llm/client.py +0 -171
  70. package/src/llm/config.py +0 -492
  71. package/src/llm/prompts.py +0 -489
  72. package/src/llm/providers.py +0 -436
  73. package/src/llm/streaming.py +0 -163
  74. package/src/llm/token_tracker.py +0 -384
  75. package/src/tools/__init__.py +0 -212
  76. package/src/tools/constants.py +0 -59
  77. package/src/tools/create_file.py +0 -136
  78. package/src/tools/directory.py +0 -389
  79. package/src/tools/edit.py +0 -545
  80. package/src/tools/file_reader.py +0 -322
  81. package/src/tools/helpers/__init__.py +0 -105
  82. package/src/tools/helpers/base.py +0 -550
  83. package/src/tools/helpers/converters.py +0 -44
  84. package/src/tools/helpers/file_helpers.py +0 -189
  85. package/src/tools/helpers/formatters.py +0 -411
  86. package/src/tools/helpers/loader.py +0 -231
  87. package/src/tools/helpers/parallel_executor.py +0 -231
  88. package/src/tools/helpers/path_resolver.py +0 -232
  89. package/src/tools/helpers/plugin_manifest.py +0 -156
  90. package/src/tools/obsidian.py +0 -96
  91. package/src/tools/review_sub_agent.py +0 -189
  92. package/src/tools/rg_search.py +0 -460
  93. package/src/tools/search_plugins.py +0 -109
  94. package/src/tools/select_option.py +0 -600
  95. package/src/tools/shell.py +0 -302
  96. package/src/tools/sub_agent.py +0 -139
  97. package/src/tools/task_list.py +0 -269
  98. package/src/tools/web_search.py +0 -61
  99. package/src/ui/__init__.py +0 -1
  100. package/src/ui/banner.py +0 -87
  101. package/src/ui/commands.py +0 -2809
  102. package/src/ui/displays.py +0 -214
  103. package/src/ui/loader.py +0 -284
  104. package/src/ui/main.py +0 -647
  105. package/src/ui/prompt_utils.py +0 -113
  106. package/src/ui/setting_selector.py +0 -590
  107. package/src/ui/setup_wizard.py +0 -294
  108. package/src/ui/sub_agent_panel.py +0 -234
  109. package/src/ui/tool_confirmation.py +0 -215
  110. package/src/utils/__init__.py +0 -1
  111. package/src/utils/citation_parser.py +0 -199
  112. package/src/utils/editor.py +0 -158
  113. package/src/utils/gitignore_filter.py +0 -149
  114. package/src/utils/logger.py +0 -254
  115. package/src/utils/paths.py +0 -30
  116. package/src/utils/result_parsers.py +0 -108
  117. package/src/utils/safe_commands.py +0 -243
  118. package/src/utils/settings.py +0 -191
  119. package/src/utils/user_message_logger.py +0 -120
  120. package/src/utils/validation.py +0 -191
  121. package/src/utils/web_search.py +0 -173
package/src/core/cron.py DELETED
@@ -1,582 +0,0 @@
1
- """Cron scheduler for bone-agent.
2
-
3
- Provides natural-language scheduled job execution integrated into the
4
- bone-agent agentic loop. Jobs are defined in ~/.bone/cron/jobs.yaml and
5
- run as background threads while bone-agent is active.
6
-
7
- External trigger: bone-agent --cron-run <job-id>
8
- """
9
-
10
- import logging
11
- import re
12
- import threading
13
- from dataclasses import dataclass, field, asdict
14
- from datetime import datetime
15
- from pathlib import Path
16
- from typing import Optional
17
-
18
- import yaml
19
-
20
- logger = logging.getLogger(__name__)
21
-
22
- # ── Paths ────────────────────────────────────────────────────────────────
23
-
24
- def _get_cron_dir() -> Path:
25
- """Return ~/.bone/cron/ directory, creating it if needed."""
26
- cron_dir = Path.home() / ".bone" / "cron"
27
- cron_dir.mkdir(parents=True, exist_ok=True)
28
- return cron_dir
29
-
30
-
31
- def _get_jobs_path() -> Path:
32
- return _get_cron_dir() / "jobs.yaml"
33
-
34
-
35
- def _get_log_dir() -> Path:
36
- log_dir = _get_cron_dir() / "logs"
37
- log_dir.mkdir(parents=True, exist_ok=True)
38
- return log_dir
39
-
40
-
41
- # ── Data model ───────────────────────────────────────────────────────────
42
-
43
- @dataclass
44
- class CronJob:
45
- """A single cron job definition."""
46
- id: str
47
- schedule: str # Natural language: "every 5 minutes", "weekdays at 8am"
48
- command: str # The prompt to feed into the agentic loop
49
- enabled: bool = True
50
- description: str = ""
51
- last_run: Optional[str] = None # ISO timestamp of last successful run
52
- last_status: Optional[str] = None # "ok" | "error"
53
- created: str = field(default_factory=lambda: datetime.now().isoformat())
54
-
55
- def to_dict(self) -> dict:
56
- return asdict(self)
57
-
58
- @classmethod
59
- def from_dict(cls, d: dict) -> "CronJob":
60
- return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
61
-
62
-
63
- # ── Schedule parser ──────────────────────────────────────────────────────
64
-
65
- # Patterns we support (ordered by specificity):
66
- # "every N minutes|hours|days"
67
- # "daily at HH:MM"
68
- # "weekdays at HH:MM"
69
- # "mondays|tuesdays|... at HH:MM"
70
- # "HH:MM" (daily shorthand)
71
-
72
- def _extract_time(m: re.Match) -> dict:
73
- """Extract hour/minute from a regex match with hour, minute, ampm groups."""
74
- hour = int(m.group("hour"))
75
- minute = int(m.group("minute") or 0)
76
- ampm = (m.group("ampm") or "").lower()
77
- if ampm == "am" and hour == 12:
78
- hour = 0
79
- elif ampm == "pm" and hour != 12:
80
- hour += 12
81
- return {"hour": hour, "minute": minute}
82
-
83
-
84
- _SCHEDULE_PATTERNS = [
85
- # every N <unit>
86
- (re.compile(
87
- r"^every\s+(?P<n>\d+)\s*(?P<unit>minute|minutes|min|m|hour|hours|hr|h|day|days|d)s?\s*$",
88
- re.IGNORECASE
89
- ), "interval"),
90
- # every day/night/morning/afternoon/evening [at] <time>
91
- (re.compile(
92
- r"^every\s+(?:day|night|morning|afternoon|evening)s?\s+(?:at\s+)?(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
93
- re.IGNORECASE
94
- ), "daily"),
95
- # weekdays at <time>
96
- (re.compile(
97
- r"^weekdays?\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
98
- re.IGNORECASE
99
- ), "weekdays"),
100
- # specific day at <time>
101
- (re.compile(
102
- r"^(?P<day>monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
103
- re.IGNORECASE
104
- ), "day_of_week"),
105
- # daily at <time>
106
- (re.compile(
107
- r"^daily\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
108
- re.IGNORECASE
109
- ), "daily"),
110
- # bare HH:MM or HHam/pm (treated as daily)
111
- (re.compile(
112
- r"^(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<ampm>[ap]m)?\s*$"
113
- ), "daily"),
114
- ]
115
-
116
- _DAY_MAP = {
117
- "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
118
- "friday": 4, "saturday": 5, "sunday": 6,
119
- }
120
-
121
-
122
- def parse_schedule(schedule: str) -> dict:
123
- """Parse a natural-language schedule into a structured spec.
124
-
125
- Returns dict with:
126
- type: "interval" | "daily" | "weekdays" | "day_of_week"
127
- For interval: interval_seconds (int)
128
- For time-based: hour (int), minute (int)
129
- For day_of_week: weekday (0=Mon..6=Sun)
130
-
131
- Raises ValueError if schedule can't be parsed.
132
- """
133
- schedule = schedule.strip()
134
- for pattern, sched_type in _SCHEDULE_PATTERNS:
135
- m = pattern.match(schedule)
136
- if m:
137
- if sched_type == "interval":
138
- n = int(m.group("n"))
139
- if n <= 0:
140
- raise ValueError(
141
- f"Interval must be at least 1: 'every {n} {m.group('unit')}'"
142
- )
143
- unit = m.group("unit").lower()
144
- if unit in ("minute", "minutes", "min", "m"):
145
- return {"type": "interval", "interval_seconds": n * 60}
146
- elif unit in ("hour", "hours", "hr", "h"):
147
- return {"type": "interval", "interval_seconds": n * 3600}
148
- elif unit in ("day", "days", "d"):
149
- return {"type": "interval", "interval_seconds": n * 86400}
150
- elif sched_type == "weekdays":
151
- t = _extract_time(m)
152
- return {"type": "weekdays", **t}
153
- elif sched_type == "day_of_week":
154
- t = _extract_time(m)
155
- return {
156
- "type": "day_of_week",
157
- "weekday": _DAY_MAP[m.group("day").lower()],
158
- **t,
159
- }
160
- elif sched_type == "daily":
161
- t = _extract_time(m)
162
- return {"type": "daily", **t}
163
-
164
- raise ValueError(
165
- f"Cannot parse schedule: '{schedule}'. "
166
- f"Examples: 'every 5 minutes', 'every hour', 'daily at 8am', "
167
- f"'every day at 5am', 'weekdays at 9:00', 'mondays at 10:30pm'"
168
- )
169
-
170
-
171
- def _should_run(spec: dict, last_run: Optional[datetime], now: datetime) -> bool:
172
- """Check if a job with the given schedule spec should run now."""
173
- if spec["type"] == "interval":
174
- interval = spec["interval_seconds"]
175
- if last_run is None:
176
- return True
177
- return (now - last_run).total_seconds() >= interval
178
-
179
- elif spec["type"] in ("daily", "weekdays", "day_of_week"):
180
- # Time-based: check if we've passed the target time today
181
- # and haven't already run today
182
- target_time = now.replace(hour=spec["hour"], minute=spec["minute"], second=0, microsecond=0)
183
-
184
- # Check day-of-week constraints
185
- if spec["type"] == "weekdays" and now.weekday() >= 5:
186
- return False
187
- if spec["type"] == "day_of_week" and now.weekday() != spec["weekday"]:
188
- return False
189
-
190
- # Has the target time passed today?
191
- if now < target_time:
192
- return False
193
-
194
- # Did we already run today (after target time)?
195
- if last_run is not None and last_run >= target_time:
196
- return False
197
-
198
- return True
199
-
200
- return False
201
-
202
-
203
- # ── Config persistence ───────────────────────────────────────────────────
204
-
205
- class CronConfig:
206
- """Load/save cron jobs from ~/.bone/cron/jobs.yaml."""
207
-
208
- def __init__(self):
209
- self._path = _get_jobs_path()
210
- self.jobs: dict[str, CronJob] = {}
211
- self.load()
212
-
213
- def load(self):
214
- self.jobs.clear()
215
- if not self._path.exists():
216
- return
217
- try:
218
- data = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
219
- for job_dict in data.get("jobs", []):
220
- job = CronJob.from_dict(job_dict)
221
- self.jobs[job.id] = job
222
- except Exception as e:
223
- logger.warning("Failed to load cron config: %s", e)
224
-
225
- def save(self):
226
- data = {"jobs": [j.to_dict() for j in self.jobs.values()]}
227
- self._path.write_text(
228
- yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
229
- encoding="utf-8",
230
- )
231
-
232
- def add_job(self, job: CronJob):
233
- self.jobs[job.id] = job
234
- self.save()
235
-
236
- def remove_job(self, job_id: str) -> bool:
237
- if job_id in self.jobs:
238
- del self.jobs[job_id]
239
- self.save()
240
- return True
241
- return False
242
-
243
- def get_job(self, job_id: str) -> Optional[CronJob]:
244
- return self.jobs.get(job_id)
245
-
246
- def update_job(self, job_id: str, **kwargs):
247
- job = self.jobs.get(job_id)
248
- if job:
249
- for k, v in kwargs.items():
250
- if k in job.__dataclass_fields__:
251
- setattr(job, k, v)
252
- self.save()
253
-
254
-
255
- # ── Scheduler ────────────────────────────────────────────────────────────
256
-
257
- def _write_job_log(job: CronJob, output: str, error: bool):
258
- """Append job output to a log file."""
259
- log_dir = _get_log_dir()
260
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
261
- log_file = log_dir / f"{job.id}_{timestamp}.log"
262
- try:
263
- log_file.write_text(
264
- f"Job: {job.id}\n"
265
- f"Schedule: {job.schedule}\n"
266
- f"Ran at: {datetime.now().isoformat()}\n"
267
- f"Status: {'ERROR' if error else 'OK'}\n"
268
- f"{'─' * 40}\n"
269
- f"{output}\n",
270
- encoding="utf-8",
271
- )
272
- except Exception as e:
273
- logger.error("Failed to write cron log: %s", e)
274
-
275
-
276
- # ── Dream job (auto-seeded) ─────────────────────────────────────────────
277
-
278
- DREAM_JOB_ID = "dream"
279
- DREAM_JOB_SCHEDULE = "daily at 4am"
280
-
281
-
282
- def ensure_dream_job(config: CronConfig) -> None:
283
- """Sync the dream memory job with the DREAM_SETTINGS.enabled config.
284
-
285
- - Enabled and missing → seed the job
286
- - Enabled and present → no-op
287
- - Disabled and present → remove the job
288
- - Disabled and missing → no-op
289
- """
290
- from utils.settings import dream_settings
291
- from llm.config import MEMORY_SETTINGS
292
-
293
- if dream_settings.enabled and MEMORY_SETTINGS.get("enabled", True):
294
- if DREAM_JOB_ID in config.jobs:
295
- return
296
- job = CronJob(
297
- id=DREAM_JOB_ID,
298
- schedule=DREAM_JOB_SCHEDULE,
299
- command="Run the dream memory consolidation process. Read yesterday's user messages from ~/.bone/conversations/, analyze them for preferences and patterns, and consolidate into memory files. Then clean up JSONL files older than 7 days.",
300
- enabled=True,
301
- description="Dream memory consolidation — scans user messages and updates memories",
302
- )
303
- config.add_job(job)
304
- logger.info("Seeded dream memory cron job (daily at 4am)")
305
- else:
306
- if DREAM_JOB_ID in config.jobs:
307
- config.remove_job(DREAM_JOB_ID)
308
- logger.info("Removed dream memory cron job (disabled in config)")
309
-
310
-
311
- def run_single_job(job: CronJob, console=None, interactive=False) -> None:
312
- """Execute a single cron job without requiring a CronScheduler instance.
313
-
314
- Used by the /cron run subcommand (interactive=True) and run_job_headless
315
- (interactive=False, default).
316
-
317
- Args:
318
- job: The CronJob to execute.
319
- console: Optional Rich console for interactive output.
320
- interactive: If True, use the real console for interactive command
321
- approval (test-run mode). Commands are auto-saved to the allow list.
322
- If False, use a buffer console (scheduled mode). Unlisted commands
323
- are blocked.
324
- """
325
- from rich.console import Console as RichConsole
326
- from io import StringIO
327
- from core.cron_allowlist import CronAllowlist
328
-
329
- # Capture output for logging
330
- output_buf = StringIO()
331
-
332
- if interactive and console is not None:
333
- # Interactive test run: use the real console so user can approve commands
334
- job_console = console
335
- else:
336
- # Scheduled run: use a buffer console (no interactive prompts)
337
- job_console = RichConsole(
338
- file=output_buf,
339
- force_terminal=True,
340
- width=80,
341
- )
342
-
343
- try:
344
- from core.chat_manager import ChatManager
345
- from core.agentic import AgenticOrchestrator
346
- from utils.paths import RG_EXE_PATH
347
- from tools.loader import load_all_tools
348
- from llm.config import TOOLS_ENABLED
349
-
350
- if not TOOLS_ENABLED:
351
- raise RuntimeError("Cron requires tools to be enabled")
352
-
353
- # Ensure tools are loaded
354
- load_all_tools()
355
-
356
- # Fresh ChatManager for this job
357
- chat_manager = ChatManager()
358
-
359
- # Dream job: auto-approve edits and run cleanup before agent starts
360
- if job.id == DREAM_JOB_ID:
361
- chat_manager.approve_mode = "accept_edits"
362
- from utils.user_message_logger import UserMessageLogger
363
- removed = UserMessageLogger.cleanup_old_files()
364
- if removed:
365
- logger.info("Dream job: removed %d old JSONL files", removed)
366
-
367
- # Build the prompt — load dream.md for dream job, else use command field
368
- if job.id == DREAM_JOB_ID:
369
- dream_prompt_path = Path(__file__).resolve().parents[2] / "prompts" / "main" / "dream.md"
370
- if dream_prompt_path.is_file():
371
- command_text = dream_prompt_path.read_text(encoding="utf-8").strip()
372
- else:
373
- command_text = job.command
374
- else:
375
- command_text = job.command
376
-
377
- prompt = (
378
- f"[Cron job: {job.id}]\n"
379
- f"{command_text}"
380
- )
381
-
382
- repo_root = Path.cwd().resolve()
383
-
384
- # Set up cron allow list for command gating
385
- allowlist = CronAllowlist()
386
-
387
- orchestrator = AgenticOrchestrator(
388
- chat_manager=chat_manager,
389
- repo_root=repo_root,
390
- rg_exe_path=RG_EXE_PATH,
391
- console=job_console,
392
- debug_mode=False,
393
- suppress_result_display=False,
394
- cron_job_id=job.id,
395
- cron_allowlist=allowlist,
396
- cron_interactive=interactive,
397
- )
398
- orchestrator.run(prompt)
399
-
400
- # Log output
401
- _write_job_log(job, output_buf.getvalue(), error=False)
402
-
403
- except Exception as e:
404
- _write_job_log(job, str(e), error=True)
405
- raise
406
-
407
-
408
- class CronScheduler:
409
- """Background scheduler that runs cron jobs via the agentic loop.
410
-
411
- Starts a daemon thread that wakes every 30 seconds to check if any
412
- jobs are due. When a job fires, it creates a fresh ChatManager
413
- (to avoid polluting the user's conversation) and runs the job's
414
- command through the agentic orchestrator.
415
- """
416
-
417
- CHECK_INTERVAL = 30 # seconds between schedule checks
418
-
419
- def __init__(self, console=None):
420
- self.config = CronConfig()
421
- self.console = console
422
- self._thread: Optional[threading.Thread] = None
423
- self._stop_event = threading.Event()
424
- self._lock = threading.Lock()
425
- self._running = False
426
-
427
- # Auto-seed the dream memory job if it doesn't exist
428
- ensure_dream_job(self.config)
429
-
430
- def start(self):
431
- """Start the cron scheduler background thread."""
432
- enabled_jobs = [j for j in self.config.jobs.values() if j.enabled]
433
-
434
- # Validate all schedules on startup
435
- for job in enabled_jobs:
436
- try:
437
- parse_schedule(job.schedule)
438
- except ValueError as e:
439
- logger.warning("Cron job '%s' has invalid schedule: %s", job.id, e)
440
-
441
- self._stop_event.clear()
442
- self._thread = None
443
- try:
444
- self._running = True
445
- self._thread = threading.Thread(
446
- target=self._run_loop,
447
- name="cron-scheduler",
448
- daemon=True,
449
- )
450
- self._thread.start()
451
- except Exception:
452
- self._running = False
453
- self._thread = None
454
- raise
455
- logger.info("Cron scheduler started with %d job(s)", len(enabled_jobs))
456
-
457
- def stop(self):
458
- """Signal the scheduler thread to stop and wait for it."""
459
- self._stop_event.set()
460
- self._running = False
461
- if self._thread and self._thread.is_alive():
462
- self._thread.join(timeout=10)
463
- logger.info("Cron scheduler stopped")
464
-
465
- def reload(self):
466
- """Reload config from disk (e.g. after /cron add/remove)."""
467
- with self._lock:
468
- self.config.load()
469
-
470
- def execute_job(self, job: CronJob):
471
- """Execute a single cron job. Public wrapper around run_single_job."""
472
- run_single_job(job, console=self.console)
473
-
474
- def _run_loop(self):
475
- """Main scheduler loop — runs in background thread."""
476
- # Track last run times from persisted state
477
- last_runs: dict[str, datetime] = {}
478
- for job in self.config.jobs.values():
479
- if job.last_run:
480
- try:
481
- last_runs[job.id] = datetime.fromisoformat(job.last_run)
482
- except (ValueError, TypeError):
483
- pass
484
-
485
- while not self._stop_event.is_set():
486
- now = datetime.now()
487
-
488
- # Collect due jobs under lock, then execute outside
489
- due_jobs: list[CronJob] = []
490
- with self._lock:
491
- for job in list(self.config.jobs.values()):
492
- if not job.enabled:
493
- continue
494
- try:
495
- spec = parse_schedule(job.schedule)
496
- except ValueError:
497
- continue
498
-
499
- last_run = last_runs.get(job.id)
500
- if _should_run(spec, last_run, now):
501
- due_jobs.append(job)
502
-
503
- # Execute jobs outside the lock so scheduling isn't blocked
504
- for job in due_jobs:
505
- logger.info("Cron firing job '%s'", job.id)
506
- try:
507
- self.execute_job(job)
508
- job.last_run = now.isoformat()
509
- job.last_status = "ok"
510
- last_runs[job.id] = now
511
- except Exception as e:
512
- logger.error("Cron job '%s' failed: %s", job.id, e)
513
- job.last_status = "error"
514
- job.last_run = now.isoformat()
515
- last_runs[job.id] = now
516
- finally:
517
- with self._lock:
518
- # Snapshot only the current job's updated state
519
- lr, ls = job.last_run, job.last_status
520
-
521
- # Reload to pick up any /cron changes made while
522
- # the job was running, so we don't overwrite them
523
- self.config.load()
524
-
525
- # Merge our last_run/last_status back onto reloaded job
526
- reloaded = self.config.jobs.get(job.id)
527
- if reloaded:
528
- reloaded.last_run = lr
529
- reloaded.last_status = ls
530
-
531
- self.config.save()
532
-
533
- self._stop_event.wait(self.CHECK_INTERVAL)
534
-
535
- # Reload config from disk so /cron add/remove changes are picked up
536
- with self._lock:
537
- self.config.load()
538
- # Sync in-memory last_runs from reloaded config
539
- # (picks up /cron run or --cron-run updates)
540
- for job in self.config.jobs.values():
541
- if job.id not in last_runs and job.last_run:
542
- try:
543
- last_runs[job.id] = datetime.fromisoformat(job.last_run)
544
- except (ValueError, TypeError):
545
- pass
546
-
547
-
548
- # ── External runner (for --cron-run) ────────────────────────────────────
549
-
550
- def run_job_headless(job_id: str) -> int:
551
- """Run a single job headlessly (no interactive session).
552
-
553
- Used by `bone-agent --cron-run <job-id>`.
554
-
555
- Returns 0 on success, 1 on failure.
556
- """
557
- config = CronConfig()
558
- job = config.get_job(job_id)
559
- if not job:
560
- print(f"Error: cron job '{job_id}' not found")
561
- return 1
562
-
563
- print(f"Running cron job: {job.id}")
564
- print(f"Schedule: {job.schedule}")
565
- print(f"Command: {job.command}")
566
- print("─" * 40)
567
-
568
- try:
569
- run_single_job(job)
570
- job.last_run = datetime.now().isoformat()
571
- job.last_status = "ok"
572
- config.save()
573
- print("─" * 40)
574
- print("Job completed successfully.")
575
- return 0
576
- except Exception as e:
577
- job.last_run = datetime.now().isoformat()
578
- job.last_status = "error"
579
- config.save()
580
- print(f"─" * 40)
581
- print(f"Job failed: {e}")
582
- return 1
@@ -1,118 +0,0 @@
1
- """Per-job command allow list for cron jobs.
2
-
3
- Stores approved shell commands per job ID in ~/.bone/cron/allowed_commands.yaml.
4
- During scheduled runs, only commands on the allow list (plus global SAFE_COMMAND_RULES)
5
- are auto-approved. Unlisted commands are blocked with agent feedback.
6
-
7
- During interactive test runs (/cron run), accepted commands are auto-saved.
8
- """
9
-
10
- import logging
11
- from pathlib import Path
12
- from typing import Optional
13
-
14
- import yaml
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- def _get_allowed_commands_path() -> Path:
20
- """Return ~/.bone/cron/allowed_commands.yaml."""
21
- cron_dir = Path.home() / ".bone" / "cron"
22
- cron_dir.mkdir(parents=True, exist_ok=True)
23
- return cron_dir / "allowed_commands.yaml"
24
-
25
-
26
- class CronAllowlist:
27
- """Manages per-job shell command allow lists.
28
-
29
- Storage format (YAML):
30
- jobs:
31
- my_job:
32
- commands:
33
- - "git add -A"
34
- - "git commit -m 'auto commit'"
35
- - "git push origin main"
36
- """
37
-
38
- def __init__(self):
39
- self._path = _get_allowed_commands_path()
40
- self._jobs: dict[str, list[str]] = {}
41
- self.load()
42
-
43
- def load(self):
44
- """Load allow list from disk."""
45
- self._jobs.clear()
46
- if not self._path.exists():
47
- return
48
- try:
49
- data = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
50
- for job_id, entry in data.get("jobs", {}).items():
51
- self._jobs[job_id] = entry.get("commands", [])
52
- except Exception as e:
53
- logger.warning("Failed to load cron allow list: %s", e)
54
-
55
- def save(self):
56
- """Persist allow list to disk."""
57
- data = {
58
- "jobs": {
59
- job_id: {"commands": cmds}
60
- for job_id, cmds in self._jobs.items()
61
- }
62
- }
63
- self._path.write_text(
64
- yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
65
- encoding="utf-8",
66
- )
67
-
68
- def get_commands(self, job_id: str) -> list[str]:
69
- """Return the list of allowed commands for a job."""
70
- return list(self._jobs.get(job_id, []))
71
-
72
- def add_command(self, job_id: str, command: str) -> bool:
73
- """Add a command to a job's allow list. Returns True if newly added."""
74
- command = command.strip()
75
- if not command:
76
- return False
77
- if job_id not in self._jobs:
78
- self._jobs[job_id] = []
79
- if command not in self._jobs[job_id]:
80
- self._jobs[job_id].append(command)
81
- self.save()
82
- return True
83
- return False
84
-
85
- def remove_command(self, job_id: str, command: str) -> bool:
86
- """Remove a command from a job's allow list. Returns True if removed."""
87
- command = command.strip()
88
- if job_id in self._jobs and command in self._jobs[job_id]:
89
- self._jobs[job_id].remove(command)
90
- if not self._jobs[job_id]:
91
- del self._jobs[job_id]
92
- self.save()
93
- return True
94
- return False
95
-
96
- def clear_job(self, job_id: str) -> int:
97
- """Remove all commands for a job. Returns count of removed commands."""
98
- if job_id not in self._jobs:
99
- return 0
100
- count = len(self._jobs[job_id])
101
- del self._jobs[job_id]
102
- self.save()
103
- return count
104
-
105
- def is_allowed(self, job_id: str, command: str) -> bool:
106
- """Check if a command is on the allow list for a job.
107
-
108
- Matching is exact (normalized whitespace). For commands that
109
- are prefixes of list entries or vice versa, we don't match --
110
- the user should approve the exact command they expect.
111
- """
112
- command = command.strip()
113
- allowed = self._jobs.get(job_id, [])
114
- return command in allowed
115
-
116
- def all_jobs(self) -> dict[str, list[str]]:
117
- """Return a copy of all job allow lists."""
118
- return {jid: list(cmds) for jid, cmds in self._jobs.items()}