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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@ All notable changes to Code Context Control (C3) are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.156.0] - 2026-10-13

### Added — Scheduled tasks

A schedule runs a Claude Code skill (`/daily_report weekly`), a free prompt or
a plain command in a project at a time or cadence, with the model, effort and
permission mode you choose, either autonomously or only after a person
approves each run. Everything is in `docs/scheduled-tasks.md`.

- The Oracle hosts the loop (`oracle/services/scheduler.py`). Each tick
claims due slots in one locked transaction (`ScheduleStore.claim_due`
advances `next_run_at` as it writes the run row, so a slot is never claimed
twice by any process), routes each run through the misfire window, the
overlap rule, an optional `check_command` gate (exit 0 = nothing to do, no
LLM cost) and the autonomy gate, and fires it as a detached shell job
(`services/schedule_runner.py`): tree-kill on timeout, no console window,
output kept, cost and tokens read back from `claude -p --output-format json`.
A kernel-held `scheduler.lock` keeps a second Oracle in standby.
- Approval mode: a pending row in `services/schedule_approvals.py` lands in
the project's feed (`kind=schedule`) for the Hub, Desk and phone; decide is
human-only and a lapsed approval answers 409. Expire-and-skip after the TTL.
- Cadences: 5-field cron with names, steps and `@daily` aliases, presets
(hourly / daily / weekdays / weekly / monthly at HH:MM), `every` seconds, or
a one-shot `at`; IANA zones or `local` via `zoneinfo` (`tzdata` on Windows).
- Surfaces: `/api/schedules/*` on the Oracle, `/api/mobile/schedules*`
(capabilities `schedules` / `schedules_write`, API version 6),
`/api/hub/schedules*` and a Hub **Schedules** tab (create, edit, pause, run
now, approve, recent runs with cost), `c3_schedule` for agents (no decide),
`c3 schedule list|add|show|rm|enable|disable|run|runs|approvals|approve|deny`.
- Shell jobs: the supervisor accepts `argv`, `stdin_text` and `env_drop`
payload keys.

## [2.155.0] - 2026-10-12

### Fixed — the hub stays up, and a `/clear` keeps its C3
Expand Down
93 changes: 92 additions & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
__version__ = "2.155.0"
__version__ = "2.156.0"

# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
Expand Down Expand Up @@ -264,6 +264,7 @@ def print_savings(savings: dict):
"mcp__c3__c3_jira", "mcp__c3__c3_credentials",
"mcp__c3__c3_project", "mcp__c3__c3_task", "mcp__c3__c3_artifacts",
"mcp__c3__c3_override", "mcp__c3__c3_ci", "mcp__c3__c3_shell_job",
"mcp__c3__c3_schedule",
]

# Obsolete MCP tool names from earlier C3 versions. `c3 permissions clean`
Expand Down Expand Up @@ -7235,6 +7236,95 @@ def _enforce_audit(result: dict, project_path: str) -> None:
_OVERRIDE_DEFAULT_TOOL = {"read": "Read", "write": "Edit"}


def cmd_schedule(args):
"""`c3 schedule` — scheduled tasks: list, add, run, approve or deny (docs/scheduled-tasks.md)."""
import json as _json

from cli.tools.schedule import build_target, build_when
from services.schedule_approvals import ApprovalError, ApprovalStore
from services.schedule_store import ScheduleError, ScheduleStore

sub = getattr(args, "schedule_cmd", None)
if not sub:
print("Usage: c3 schedule <list|add|show|rm|enable|disable|run|runs|approvals|approve|deny> ...")
return
store = ScheduleStore()
approvals = ApprovalStore()
try:
if sub == "list":
rows = store.list()
if not rows:
print("No schedules. Add one with `c3 schedule add`.")
for row in rows:
when = row.get("when") or {}
cadence = when.get("expr") or (f"every {when.get('seconds')}s" if when.get("type") == "every"
else when.get("when", ""))
flag = "on " if row.get("enabled") else "off"
print(f" {row['id']} [{flag}] {row['name']!r} {row['kind']} @ {cadence} {row['autonomy']} "
f"next={row.get('next_run_at') or '-'} last={row.get('last_status') or '-'} "
f"{row['project_path']}")
elif sub == "show":
row = store.get(args.schedule_id)
if row is None:
raise ScheduleError(f"no schedule {args.schedule_id}")
print(_json.dumps(row, indent=2, ensure_ascii=False))
elif sub == "add":
from oracle.config import load_config
from oracle.services.scheduler import defaults_from_config
when = build_when(cron=args.cron, every=args.every, at=args.at, preset=args.preset, tz=args.tz,
preset_at=args.preset_at, days=args.days)
if when is None:
raise ScheduleError("give one of --cron, --every, --at or --preset")
fields = {
"name": args.name, "kind": args.kind, "target": build_target(args.kind, args.target),
"provider": args.provider, "model": args.model, "effort": args.effort,
"autonomy": args.autonomy, "project_path": str(Path(args.project_path).resolve()),
"when": when, "overlap": args.overlap, "check_command": args.check_command,
}
if args.timeout_s:
fields["timeout_s"] = args.timeout_s
if args.max_budget_usd:
fields["max_budget_usd"] = args.max_budget_usd
if args.permission_mode:
fields["permission_mode"] = args.permission_mode
row = store.create(fields, defaults=defaults_from_config(load_config()), created_by="cli")
print(f"Created {row['id']} {row['name']!r}; next run {row['next_run_at']}")
elif sub in ("enable", "disable"):
row = store.set_enabled(args.schedule_id, sub == "enable", by="cli")
print(f"{row['id']} {sub}d; next run {row.get('next_run_at') or '-'}")
elif sub == "rm":
if not store.delete(args.schedule_id, by="cli"):
raise ScheduleError(f"no schedule {args.schedule_id}")
print(f"Deleted {args.schedule_id}")
elif sub == "run":
if store.get(args.schedule_id) is None:
raise ScheduleError(f"no schedule {args.schedule_id}")
run = store.request_run(args.schedule_id, trigger="manual", by="cli", approved=args.bypass_approval)
print(f"Queued run {run['run_id']} ({run['status']}); the Oracle dispatches it on its next tick")
elif sub == "runs":
rows = store.runs(schedule_id=args.schedule_id or None, status=args.status or None, limit=args.limit)
if not rows:
print("No runs.")
for run in rows:
cost = f" ${run['cost_usd']:.3f}" if run.get("cost_usd") is not None else ""
err = f" — {str(run['error'])[:80]}" if run.get("error") else ""
print(f" {run['run_id']} {run['status']:<17} {run['trigger']:<8} {run['schedule_name']!r} "
f"{(run.get('claimed_at') or '')[:19]}{cost}{err}")
elif sub == "approvals":
rows = approvals.list(status=args.status or "pending")
if not rows:
print(f"No {args.status or 'pending'} approvals.")
for row in rows:
print(f" {row['id']} {row['status']:<8} {row['schedule_name']!r} {row['summary']} "
f"expires={row['expires_at']}")
elif sub in ("approve", "deny"):
row = approvals.decide(args.approval_id, sub, decided_by="cli", note=args.note or "")
print(f"{row['id']} {row['status']}; the Oracle acts on it on its next tick")
except (ScheduleError, ApprovalError) as exc:
print(f"Error: {exc}")
sys.exit(1)


def cmd_override(args):
"""`c3 override` — Override Requests: allow one blocked call, once.

Expand Down Expand Up @@ -9663,6 +9753,7 @@ def main():
"access": cmd_access,
"enforce": cmd_enforce,
"override": cmd_override,
"schedule": cmd_schedule,
"locks": cmd_locks,
"prune-quarantine": cmd_prune_quarantine,
"ci": cmd_ci,
Expand Down
50 changes: 50 additions & 0 deletions cli/commands/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,56 @@ def _ci_common(sub):
ov_costs.add_argument("--path", dest="project_path", default=".",
help="Project directory (default: current)")

# ── Scheduled tasks (docs/scheduled-tasks.md) ─────────────────────────
# The human CLI over the same stores the Oracle fires from. approve/deny
# live here and on the Hub/Desk/phone, never on the c3_schedule agent tool.
p_schedule = subparsers.add_parser(
"schedule", help="Scheduled tasks — skills, prompts or commands the Oracle fires on a cadence")
sched_subs = p_schedule.add_subparsers(dest="schedule_cmd")
sched_subs.add_parser("list", help="Every schedule on this machine")
sc_show = sched_subs.add_parser("show", help="One schedule as JSON")
sc_show.add_argument("schedule_id")
sc_add = sched_subs.add_parser("add", help="Create a schedule")
sc_add.add_argument("name")
sc_add.add_argument("target", help="'/skill args', a prompt, or a command (see --kind)")
sc_add.add_argument("--kind", choices=["skill", "prompt", "command"], default="skill")
sc_add.add_argument("--cron", default="", help="5-field cron, e.g. '0 9 * * 1-5'")
sc_add.add_argument("--every", type=int, default=0, help="Seconds between runs")
sc_add.add_argument("--at", default="", help="One-shot ISO datetime")
sc_add.add_argument("--preset", choices=["hourly", "daily", "weekdays", "weekly", "monthly"], default="")
sc_add.add_argument("--preset-at", dest="preset_at", default="09:00", help="HH:MM for --preset")
sc_add.add_argument("--days", default="", help="Weekly preset days, e.g. mon,fri")
sc_add.add_argument("--tz", default="local", help="IANA zone or 'local'")
sc_add.add_argument("--provider", choices=["claude", "codex", "gemini", "grok"], default="claude")
sc_add.add_argument("--model", default="")
sc_add.add_argument("--effort", choices=["", "low", "medium", "high", "xhigh", "max"], default="")
sc_add.add_argument("--autonomy", choices=["autonomous", "approval"], default="approval")
sc_add.add_argument("--permission-mode", dest="permission_mode", default="")
sc_add.add_argument("--timeout", dest="timeout_s", type=int, default=0)
sc_add.add_argument("--budget", dest="max_budget_usd", type=float, default=0.0, help="--max-budget-usd cap")
sc_add.add_argument("--overlap", choices=["skip", "allow"], default="skip")
sc_add.add_argument("--check", dest="check_command", default="",
help="Command whose exit 1 arms the run; exit 0 skips it")
sc_add.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
for verb, help_text in (("enable", "Resume a schedule"), ("disable", "Pause a schedule"),
("rm", "Delete a schedule")):
sp = sched_subs.add_parser(verb, help=help_text)
sp.add_argument("schedule_id")
sc_run = sched_subs.add_parser("run", help="Queue a run now")
sc_run.add_argument("schedule_id")
sc_run.add_argument("--bypass-approval", dest="bypass_approval", action="store_true",
help="Skip the approval gate for this one run")
sc_runs = sched_subs.add_parser("runs", help="Recent runs")
sc_runs.add_argument("--schedule", dest="schedule_id", default="")
sc_runs.add_argument("--status", default="")
sc_runs.add_argument("--limit", type=int, default=30)
sc_appr = sched_subs.add_parser("approvals", help="Pending (or --status) approvals")
sc_appr.add_argument("--status", default="")
for verb in ("approve", "deny"):
sp = sched_subs.add_parser(verb, help=f"{verb.capitalize()} one pending scheduled run")
sp.add_argument("approval_id")
sp.add_argument("--note", default="")

# ── Tool discipline / enforcement mode (v2.66.0) ────────────────────
# LAYER C: how hard C3 pushes the agent toward c3_* tools. Distinct from
# `c3 access` (path policy — a security boundary) and from
Expand Down
45 changes: 45 additions & 0 deletions cli/guide/tools.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
<a href="#c3_impact" class="sidebar-link"><span class="icon">💥</span> c3_impact</a>
<a href="#c3_shell" class="sidebar-link"><span class="icon">⚡</span> c3_shell</a>
<a href="#c3_shell_job" class="sidebar-link"><span class="icon">🕐</span> c3_shell_job</a>
<a href="#c3_schedule" class="sidebar-link"><span class="icon">📅</span> c3_schedule</a>
<a href="#c3_ci" class="sidebar-link"><span class="icon">🧪</span> c3_ci</a>
</div>
<div class="sidebar-section">
Expand Down Expand Up @@ -166,6 +167,7 @@ <h3>All Tools</h3>
<a href="#c3_impact" class="toc-item">c3_impact <span class="cat">Analysis</span></a>
<a href="#c3_shell" class="toc-item">c3_shell <span class="cat">Execute</span></a>
<a href="#c3_shell_job" class="toc-item">c3_shell_job <span class="cat">Execute</span></a>
<a href="#c3_schedule" class="toc-item">c3_schedule <span class="cat">Execute</span></a>
<a href="#c3_ci" class="toc-item">c3_ci <span class="cat">Execute</span></a>
<a href="#c3_edit" class="toc-item">c3_edit <span class="cat">Write</span></a>
<a href="#c3_validate" class="toc-item">c3_validate <span class="cat">Write</span></a>
Expand Down Expand Up @@ -1602,6 +1604,49 @@ <h4>Examples</h4>
</div>
</div>

<!-- c3_schedule -->
<div class="tool-card" id="c3_schedule">
<div class="tool-card-header">
<span class="tool-name">c3_schedule</span>
<div class="tag-row">
<span class="badge badge-purple">execute</span>
<span class="badge">v2.156.0</span>
</div>
<span class="tool-tagline">Scheduled tasks — a skill, prompt or command the Oracle fires on a cadence, autonomously or after a human approves each run</span>
</div>
<div class="tool-card-body">
<p class="tool-desc">A schedule names what to run (<code>kind='skill'</code> with <code>target='/daily_report weekly'</code>, a free <code>prompt</code>, or a plain <code>command</code> with no LLM), where (a registered project), how (<code>provider</code> claude / codex / gemini / grok, <code>model</code>, <code>effort</code>, <code>permission_mode</code>, <code>max_budget_usd</code>) and when (<code>cron</code>, <code>preset</code>, <code>every</code> seconds, or a one-shot <code>at</code>). The Oracle's scheduler claims each due slot once, runs it as a detached shell job in the project, records exit, duration, cost and tokens, and writes a <code>kind=schedule</code> notification the Hub, Desk and phone show. <code>autonomy='approval'</code> (the default from an agent) creates a pending approval instead of firing; nobody approving before the TTL means the slot is logged <code>expired</code> and skipped. Deciding is human-only — the Hub Schedules tab, the Desk, the phone or <code>c3 schedule approve</code>. See <code>docs/scheduled-tasks.md</code>.</p>

<h4>Actions</h4>
<table class="params-table">
<thead><tr><th>Action</th><th>Group</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="param-name">create</td><td>Write</td><td class="param-desc"><code>name</code>, <code>kind</code>, <code>target</code>, one cadence (<code>cron</code> + <code>tz</code> | <code>preset</code> + <code>preset_at</code> [+ <code>days</code>] | <code>every</code> | <code>at</code>), then <code>provider</code>, <code>model</code>, <code>effort</code>, <code>autonomy</code>, <code>project</code>, <code>cwd</code>, <code>timeout</code>, <code>max_budget_usd</code>, <code>permission_mode</code>, <code>allowed_tools</code>, <code>check_command</code> (exit 1 arms the run, exit 0 skips it for free), <code>overlap</code>, <code>misfire_grace_s</code></td></tr>
<tr><td class="param-name param-list">update, enable, disable, delete</td><td>Write</td><td class="param-desc"><code>schedule_id</code> + changed fields; a disabled schedule keeps its history</td></tr>
<tr><td class="param-name">run_now</td><td>Write</td><td class="param-desc">Queue one run outside the cadence; approval-mode schedules still ask</td></tr>
<tr><td class="param-name param-list">list, get, runs, approvals, status</td><td>Read</td><td class="param-desc">Schedules, one row, recent runs ([<code>schedule_id</code>] [<code>status</code>] [<code>limit</code>]), pending approvals, the loop's state</td></tr>
<tr><td class="param-name">preview</td><td>Read</td><td class="param-desc">A cadence → its next fire times, before you commit to it</td></tr>
</tbody>
</table>

<h4>Examples</h4>
<pre><code><span class="com"># A weekday report, sonnet at medium effort, asking first</span>
c3_schedule(action=<span class="str">'create'</span>, name=<span class="str">'daily report'</span>, kind=<span class="str">'skill'</span>,
target=<span class="str">'/daily_report'</span>, preset=<span class="str">'weekdays'</span>, preset_at=<span class="str">'09:00'</span>,
model=<span class="str">'sonnet'</span>, effort=<span class="str">'medium'</span>, max_budget_usd=<span class="num">1.0</span>)

<span class="com"># A free command every hour, no LLM, fires on its own</span>
c3_schedule(action=<span class="str">'create'</span>, name=<span class="str">'fetch'</span>, kind=<span class="str">'command'</span>,
target=<span class="str">'git fetch --all --prune'</span>, every=<span class="num">3600</span>, autonomy=<span class="str">'autonomous'</span>)

<span class="com"># See what a cron would do, then what ran</span>
c3_schedule(action=<span class="str">'preview'</span>, cron=<span class="str">'0 18 * * 1-5'</span>, tz=<span class="str">'Europe/Berlin'</span>)
c3_schedule(action=<span class="str">'runs'</span>, limit=<span class="num">10</span>)</code></pre>

<p class="tool-desc"><strong>Surfaces:</strong> the Hub's Schedules tab (create, edit, pause, run now, approve or deny), <code>/api/mobile/schedules</code> for the Desk and the phone, and <code>c3 schedule</code> on the command line.</p>
</div>
</div>

<!-- c3_artifacts -->
<div class="tool-card" id="c3_artifacts">
<div class="tool-card-header">
Expand Down
3 changes: 2 additions & 1 deletion cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def _hub_allowed_hosts():

# Top-bar tabs a persisted ``main_view`` may name (cli/hub_ui/components/topbar.js).
_MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access",
"enforce", "sessions"}
"enforce", "sessions", "schedules"}

# Stock command per IDE id, and whether it takes the project path as an
# argument (GUI) instead of being run inside a terminal at that path.
Expand Down Expand Up @@ -400,6 +400,7 @@ def _win_find_ide(cmd: str) -> str:
"hub_ui/components/hub_enforcement.js",
"hub_ui/components/hub_sessions.js",
"hub_ui/components/hub_ci.js",
"hub_ui/components/hub_schedules.js",
"hub_ui/components/drill_subprojects.js",
"hub_ui/components/drill_health.js",
"hub_ui/components/drill_tasks.js",
Expand Down
Loading
Loading