bone-agent 1.4.0 → 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 (126) 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 -201
  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 -144
  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 -50
  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/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
package/src/core/cron.py DELETED
@@ -1,578 +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 llm.config import TOOLS_ENABLED
348
-
349
- if not TOOLS_ENABLED:
350
- raise RuntimeError("Cron requires tools to be enabled")
351
-
352
- # Fresh ChatManager for this job
353
- chat_manager = ChatManager()
354
-
355
- # Dream job: auto-approve edits and run cleanup before agent starts
356
- if job.id == DREAM_JOB_ID:
357
- chat_manager.approve_mode = "accept_edits"
358
- from utils.user_message_logger import UserMessageLogger
359
- removed = UserMessageLogger.cleanup_old_files()
360
- if removed:
361
- logger.info("Dream job: removed %d old JSONL files", removed)
362
-
363
- # Build the prompt — load dream.md for dream job, else use command field
364
- if job.id == DREAM_JOB_ID:
365
- dream_prompt_path = Path(__file__).resolve().parents[2] / "prompts" / "main" / "dream.md"
366
- if dream_prompt_path.is_file():
367
- command_text = dream_prompt_path.read_text(encoding="utf-8").strip()
368
- else:
369
- command_text = job.command
370
- else:
371
- command_text = job.command
372
-
373
- prompt = (
374
- f"[Cron job: {job.id}]\n"
375
- f"{command_text}"
376
- )
377
-
378
- repo_root = Path.cwd().resolve()
379
-
380
- # Set up cron allow list for command gating
381
- allowlist = CronAllowlist()
382
-
383
- orchestrator = AgenticOrchestrator(
384
- chat_manager=chat_manager,
385
- repo_root=repo_root,
386
- rg_exe_path=RG_EXE_PATH,
387
- console=job_console,
388
- debug_mode=False,
389
- suppress_result_display=False,
390
- cron_job_id=job.id,
391
- cron_allowlist=allowlist,
392
- cron_interactive=interactive,
393
- )
394
- orchestrator.run(prompt)
395
-
396
- # Log output
397
- _write_job_log(job, output_buf.getvalue(), error=False)
398
-
399
- except Exception as e:
400
- _write_job_log(job, str(e), error=True)
401
- raise
402
-
403
-
404
- class CronScheduler:
405
- """Background scheduler that runs cron jobs via the agentic loop.
406
-
407
- Starts a daemon thread that wakes every 30 seconds to check if any
408
- jobs are due. When a job fires, it creates a fresh ChatManager
409
- (to avoid polluting the user's conversation) and runs the job's
410
- command through the agentic orchestrator.
411
- """
412
-
413
- CHECK_INTERVAL = 30 # seconds between schedule checks
414
-
415
- def __init__(self, console=None):
416
- self.config = CronConfig()
417
- self.console = console
418
- self._thread: Optional[threading.Thread] = None
419
- self._stop_event = threading.Event()
420
- self._lock = threading.Lock()
421
- self._running = False
422
-
423
- # Auto-seed the dream memory job if it doesn't exist
424
- ensure_dream_job(self.config)
425
-
426
- def start(self):
427
- """Start the cron scheduler background thread."""
428
- enabled_jobs = [j for j in self.config.jobs.values() if j.enabled]
429
-
430
- # Validate all schedules on startup
431
- for job in enabled_jobs:
432
- try:
433
- parse_schedule(job.schedule)
434
- except ValueError as e:
435
- logger.warning("Cron job '%s' has invalid schedule: %s", job.id, e)
436
-
437
- self._stop_event.clear()
438
- self._thread = None
439
- try:
440
- self._running = True
441
- self._thread = threading.Thread(
442
- target=self._run_loop,
443
- name="cron-scheduler",
444
- daemon=True,
445
- )
446
- self._thread.start()
447
- except Exception:
448
- self._running = False
449
- self._thread = None
450
- raise
451
- logger.info("Cron scheduler started with %d job(s)", len(enabled_jobs))
452
-
453
- def stop(self):
454
- """Signal the scheduler thread to stop and wait for it."""
455
- self._stop_event.set()
456
- self._running = False
457
- if self._thread and self._thread.is_alive():
458
- self._thread.join(timeout=10)
459
- logger.info("Cron scheduler stopped")
460
-
461
- def reload(self):
462
- """Reload config from disk (e.g. after /cron add/remove)."""
463
- with self._lock:
464
- self.config.load()
465
-
466
- def execute_job(self, job: CronJob):
467
- """Execute a single cron job. Public wrapper around run_single_job."""
468
- run_single_job(job, console=self.console)
469
-
470
- def _run_loop(self):
471
- """Main scheduler loop — runs in background thread."""
472
- # Track last run times from persisted state
473
- last_runs: dict[str, datetime] = {}
474
- for job in self.config.jobs.values():
475
- if job.last_run:
476
- try:
477
- last_runs[job.id] = datetime.fromisoformat(job.last_run)
478
- except (ValueError, TypeError):
479
- pass
480
-
481
- while not self._stop_event.is_set():
482
- now = datetime.now()
483
-
484
- # Collect due jobs under lock, then execute outside
485
- due_jobs: list[CronJob] = []
486
- with self._lock:
487
- for job in list(self.config.jobs.values()):
488
- if not job.enabled:
489
- continue
490
- try:
491
- spec = parse_schedule(job.schedule)
492
- except ValueError:
493
- continue
494
-
495
- last_run = last_runs.get(job.id)
496
- if _should_run(spec, last_run, now):
497
- due_jobs.append(job)
498
-
499
- # Execute jobs outside the lock so scheduling isn't blocked
500
- for job in due_jobs:
501
- logger.info("Cron firing job '%s'", job.id)
502
- try:
503
- self.execute_job(job)
504
- job.last_run = now.isoformat()
505
- job.last_status = "ok"
506
- last_runs[job.id] = now
507
- except Exception as e:
508
- logger.error("Cron job '%s' failed: %s", job.id, e)
509
- job.last_status = "error"
510
- job.last_run = now.isoformat()
511
- last_runs[job.id] = now
512
- finally:
513
- with self._lock:
514
- # Snapshot only the current job's updated state
515
- lr, ls = job.last_run, job.last_status
516
-
517
- # Reload to pick up any /cron changes made while
518
- # the job was running, so we don't overwrite them
519
- self.config.load()
520
-
521
- # Merge our last_run/last_status back onto reloaded job
522
- reloaded = self.config.jobs.get(job.id)
523
- if reloaded:
524
- reloaded.last_run = lr
525
- reloaded.last_status = ls
526
-
527
- self.config.save()
528
-
529
- self._stop_event.wait(self.CHECK_INTERVAL)
530
-
531
- # Reload config from disk so /cron add/remove changes are picked up
532
- with self._lock:
533
- self.config.load()
534
- # Sync in-memory last_runs from reloaded config
535
- # (picks up /cron run or --cron-run updates)
536
- for job in self.config.jobs.values():
537
- if job.id not in last_runs and job.last_run:
538
- try:
539
- last_runs[job.id] = datetime.fromisoformat(job.last_run)
540
- except (ValueError, TypeError):
541
- pass
542
-
543
-
544
- # ── External runner (for --cron-run) ────────────────────────────────────
545
-
546
- def run_job_headless(job_id: str) -> int:
547
- """Run a single job headlessly (no interactive session).
548
-
549
- Used by `bone-agent --cron-run <job-id>`.
550
-
551
- Returns 0 on success, 1 on failure.
552
- """
553
- config = CronConfig()
554
- job = config.get_job(job_id)
555
- if not job:
556
- print(f"Error: cron job '{job_id}' not found")
557
- return 1
558
-
559
- print(f"Running cron job: {job.id}")
560
- print(f"Schedule: {job.schedule}")
561
- print(f"Command: {job.command}")
562
- print("─" * 40)
563
-
564
- try:
565
- run_single_job(job)
566
- job.last_run = datetime.now().isoformat()
567
- job.last_status = "ok"
568
- config.save()
569
- print("─" * 40)
570
- print("Job completed successfully.")
571
- return 0
572
- except Exception as e:
573
- job.last_run = datetime.now().isoformat()
574
- job.last_status = "error"
575
- config.save()
576
- print(f"─" * 40)
577
- print(f"Job failed: {e}")
578
- 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()}