From eb4f9cc90e1079a6cad929aa249173cbcb85b797 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 13 Oct 2026 10:08:45 -0400 Subject: [PATCH 1/3] feat(schedule): c3_schedule MCP tool and the c3 schedule CLI Agents propose and inspect schedules with c3_schedule (create defaults to autonomy=approval and the session's project; list, get, update, enable, disable, delete, run_now, runs, preview, approvals, status). There is no decide action: approving is human-only, here via `c3 schedule approve|deny`, which joins list/add/show/rm/enable/disable/run/runs/approvals. Registered in _C3_MCP_ALLOW. Verified: 8 handler tests plus the permissions registry test. Claude-Session: https://claude.ai/code/session_01JUcvmQq81aLo9ewD9Xc4qY --- cli/c3.py | 93 ++++++++++++++- cli/commands/parser.py | 50 ++++++++ cli/mcp_server.py | 60 ++++++++++ cli/tools/schedule.py | 226 +++++++++++++++++++++++++++++++++++++ tests/test_schedule_mcp.py | 106 +++++++++++++++++ 5 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 cli/tools/schedule.py create mode 100644 tests/test_schedule_mcp.py diff --git a/cli/c3.py b/cli/c3.py index 4d0df99..a40fb22 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -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" @@ -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` @@ -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 ...") + 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. @@ -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, diff --git a/cli/commands/parser.py b/cli/commands/parser.py index 281638f..9993aad 100644 --- a/cli/commands/parser.py +++ b/cli/commands/parser.py @@ -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 diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 23e940e..609ffa7 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -1599,6 +1599,66 @@ def finalize(fname, fargs, fresp, fsumm, **kw): parent=parent, minutes=minutes) +@mcp.tool() +async def c3_schedule( + action: str, + schedule_id: str = "", + name: str = "", + kind: str = "skill", + target: str = "", + cron: str = "", + every: int = 0, + at: str = "", + preset: str = "", + preset_at: str = "09:00", + days: str = "", + tz: str = "local", + provider: str = "claude", + model: str = "", + effort: str = "", + autonomy: str = "approval", + project: str = "", + cwd: str = "", + enabled: bool = True, + timeout: int = 0, + max_budget_usd: float = 0.0, + permission_mode: str = "", + allowed_tools: str = "", + check_command: str = "", + overlap: str = "skip", + misfire_grace_s: int = -1, + run_id: str = "", + status: str = "", + limit: int = 20, + ctx: Context = None, +) -> str: + """SCHEDULED TASKS — propose and inspect jobs the Oracle fires on a cadence (docs/scheduled-tasks.md). Reads safe in plan mode. + A schedule runs a Claude Code skill (kind='skill', target='/daily_report weekly'), a prompt (kind='prompt') or a + plain command (kind='command', no LLM) in a registered project, with model / effort / permission_mode, either + autonomy='autonomous' or 'approval' (default from an agent: a human approves each run from the Hub, Desk or phone). + Cadence: cron='0 9 * * 1-5' (+tz), preset='hourly|daily|weekdays|weekly|monthly' (+preset_at='09:00', days='mon,fri'), + every=, or at= (one-shot). + Actions: create (name, kind, target, one cadence, [provider, model, effort, autonomy, project, cwd, timeout, + max_budget_usd, permission_mode, allowed_tools, check_command, overlap=skip|allow, misfire_grace_s]), + update (schedule_id + changed fields), enable, disable, delete, run_now (queues a run for the next tick), + list, get, runs ([schedule_id] [status] [limit]), preview (a cadence → next fire times), approvals, status. + Deciding an approval is human-only: Hub Schedules tab, Desk, phone, or `c3 schedule approve `.""" + svc = _svc(ctx) + + def finalize(fname, fargs, fresp, fsumm, **kw): + return _finalize_response(ctx, fname, fargs, fresp, fsumm, **kw) + + from cli.tools.schedule import handle_schedule + return await asyncio.to_thread( + handle_schedule, action, svc, finalize, + schedule_id=schedule_id, name=name, kind=kind, target=target, cron=cron, every=every, at=at, + preset=preset, preset_at=preset_at, days=days, tz=tz, provider=provider, model=model, + effort=effort, autonomy=autonomy, project=project, cwd=cwd, enabled=enabled, timeout=timeout, + max_budget_usd=max_budget_usd, permission_mode=permission_mode, allowed_tools=allowed_tools, + check_command=check_command, overlap=overlap, misfire_grace_s=misfire_grace_s, run_id=run_id, + status=status, limit=limit) + + @mcp.tool() async def c3_artifacts( action: str, diff --git a/cli/tools/schedule.py b/cli/tools/schedule.py new file mode 100644 index 0000000..b213e35 --- /dev/null +++ b/cli/tools/schedule.py @@ -0,0 +1,226 @@ +"""c3_schedule — propose, inspect and run scheduled tasks from an agent session. + +Deciding a pending approval is human-only (Hub, Desk, phone, ``c3 schedule +approve``); there is deliberately no ``decide`` action here. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +READ_ACTIONS = {"list", "get", "runs", "preview", "approvals", "status"} +ACTIONS = ("list", "get", "create", "update", "delete", "enable", "disable", + "run_now", "runs", "preview", "approvals", "status") + + +def _store(): + from services.schedule_store import ScheduleStore + return ScheduleStore() + + +def _approvals(): + from services.schedule_approvals import ApprovalStore + return ApprovalStore() + + +def _defaults() -> dict: + from oracle.config import load_config + from oracle.services.scheduler import defaults_from_config + return defaults_from_config(load_config()) + + +def build_when(*, cron: str = "", every: int = 0, at: str = "", preset: str = "", + tz: str = "local", preset_at: str = "09:00", days: str = "") -> dict | None: + """The ``when`` object the flat tool parameters describe, or None when none was given.""" + if cron: + return {"type": "cron", "expr": cron, "tz": tz or "local"} + if preset: + day_list = [d.strip() for d in (days or "").split(",") if d.strip()] + return {"type": "preset", "preset": preset, "at": preset_at or "09:00", "days": day_list, + "tz": tz or "local"} + if at: + return {"type": "at", "when": at} + if every: + return {"type": "every", "seconds": int(every)} + return None + + +def build_target(kind: str, target: str) -> dict: + text = (target or "").strip() + if kind == "command": + return {"cmd": text} + if kind == "prompt": + return {"prompt": text} + skill, _, args = text.partition(" ") + return {"skill": skill, "args": args.strip()} + + +def _fmt_schedule(row: dict) -> str: + 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", "")) + target = row.get("target") or {} + what = target.get("skill") or target.get("cmd") or (target.get("prompt") or "")[:40] + flag = "on " if row.get("enabled") else "off" + model = f" {row['provider']}" + (f"/{row['model']}" if row.get("model") else "") + if row.get("kind") == "command": + model = "" + return (f" {row['id']} [{flag}] {row['name']!r} {row['kind']} {what}{model} " + f"@ {cadence} {row.get('autonomy')} next={row.get('next_run_at') or '-'} " + f"last={row.get('last_status') or '-'}") + + +def _fmt_run(run: dict) -> str: + cost = f" ${run['cost_usd']:.3f}" if run.get("cost_usd") is not None else "" + secs = f" {run['duration_ms'] / 1000:.1f}s" if run.get("duration_ms") else "" + err = f" — {str(run['error'])[:80]}" if run.get("error") else "" + return (f" {run['run_id']} {run.get('status'):<17} {run.get('trigger')} {run.get('schedule_name')!r} " + f"{run.get('claimed_at', '')[:19]}{secs}{cost}{err}") + + +def handle_schedule(action, svc, finalize, *, schedule_id="", name="", kind="skill", target="", + cron="", every=0, at="", preset="", preset_at="09:00", days="", tz="local", + provider="claude", model="", effort="", autonomy="approval", project="", + cwd="", enabled=True, timeout=0, max_budget_usd=0.0, permission_mode="", + allowed_tools="", check_command="", overlap="skip", misfire_grace_s=-1, + run_id="", status="", limit=20) -> str: + action = (action or "").strip().lower() + + def done(resp, summ="ok"): + return finalize("c3_schedule", {"action": action}, resp, summ) + + if action not in ACTIONS: + return done(f"[schedule:error] action must be one of {', '.join(ACTIONS)}", "error") + + from services.schedule_store import RevConflict, ScheduleError + + project_path = str(Path(project or getattr(svc, "project_path", ".") or ".").resolve()) + store = _store() + + try: + if action == "list": + rows = store.list(project_path=project_path if project else None) + if not rows: + return done("[schedule:list] no schedules", "0") + return done(f"[schedule:list] {len(rows)} schedule(s)\n" + "\n".join(_fmt_schedule(r) for r in rows), + str(len(rows))) + + if action == "get": + row = store.get(schedule_id) + if row is None: + return done(f"[schedule:error] no schedule {schedule_id}", "error") + import json + return done(f"[schedule:get]\n{json.dumps(row, indent=2, ensure_ascii=False)}", schedule_id) + + if action == "create": + when = build_when(cron=cron, every=every, at=at, preset=preset, tz=tz, preset_at=preset_at, days=days) + if when is None: + return done("[schedule:error] create needs one of cron, every, at or preset", "error") + fields = { + "name": name, "kind": kind, "target": build_target(kind, target), "provider": provider, + "model": model, "effort": effort, "autonomy": autonomy or "approval", + "project_path": project_path, "enabled": bool(enabled), "when": when, + "overlap": overlap, "check_command": check_command, "allowed_tools": allowed_tools, + } + if cwd: + fields["cwd"] = cwd + if timeout: + fields["timeout_s"] = int(timeout) + if max_budget_usd: + fields["max_budget_usd"] = float(max_budget_usd) + if permission_mode: + fields["permission_mode"] = permission_mode + if misfire_grace_s >= 0: + fields["misfire"] = {"policy": "grace", "grace_s": int(misfire_grace_s)} + row = store.create(fields, defaults=_defaults(), created_by="mcp") + gate = "fires autonomously" if row["autonomy"] == "autonomous" else "asks for approval before each run" + return done(f"[schedule:created] {row['id']} {row['name']!r} next={row['next_run_at']} — {gate}", + row["id"]) + + if action == "update": + if not schedule_id: + return done("[schedule:error] update needs schedule_id", "error") + patch: dict = {} + when = build_when(cron=cron, every=every, at=at, preset=preset, tz=tz, preset_at=preset_at, days=days) + if when is not None: + patch["when"] = when + if name: + patch["name"] = name + if target: + existing = store.get(schedule_id) or {} + patch["kind"] = kind or existing.get("kind", "skill") + patch["target"] = build_target(patch["kind"], target) + for key, value in (("model", model), ("effort", effort), ("provider", provider if provider != "claude" else ""), + ("cwd", cwd), ("permission_mode", permission_mode), + ("allowed_tools", allowed_tools), ("check_command", check_command)): + if value: + patch[key] = value + if autonomy and autonomy != "approval": + patch["autonomy"] = autonomy + if timeout: + patch["timeout_s"] = int(timeout) + if max_budget_usd: + patch["max_budget_usd"] = float(max_budget_usd) + if overlap != "skip": + patch["overlap"] = overlap + if misfire_grace_s >= 0: + patch["misfire"] = {"policy": "grace", "grace_s": int(misfire_grace_s)} + if not patch: + return done("[schedule:error] update: nothing to change", "error") + row = store.update(schedule_id, patch, defaults=_defaults(), by="mcp") + return done(f"[schedule:updated] {row['id']} fields={sorted(patch)} next={row['next_run_at']}", row["id"]) + + if action in ("enable", "disable"): + row = store.set_enabled(schedule_id, action == "enable", by="mcp") + return done(f"[schedule:{action}d] {row['id']} next={row['next_run_at'] or '-'}", row["id"]) + + if action == "delete": + if not store.delete(schedule_id, by="mcp"): + return done(f"[schedule:error] no schedule {schedule_id}", "error") + return done(f"[schedule:deleted] {schedule_id}", schedule_id) + + if action == "run_now": + row = store.get(schedule_id) + if row is None: + return done(f"[schedule:error] no schedule {schedule_id}", "error") + run = store.request_run(schedule_id, trigger="manual", by="mcp") + gate = "" if row.get("autonomy") == "autonomous" else " (needs a human approval first)" + return done(f"[schedule:queued] run {run['run_id']} for {row['name']!r}; the Oracle dispatches it on " + f"its next tick{gate}", run["run_id"]) + + if action == "runs": + rows = store.runs(schedule_id=schedule_id or None, status=status or None, + project_path=None if schedule_id else project_path, limit=limit) + if not rows: + return done("[schedule:runs] no runs", "0") + return done(f"[schedule:runs] {len(rows)} run(s)\n" + "\n".join(_fmt_run(r) for r in rows), str(len(rows))) + + if action == "preview": + from services.cron_expr import preview, validate_when + when = build_when(cron=cron, every=every, at=at, preset=preset, tz=tz, preset_at=preset_at, days=days) + if when is None: + return done("[schedule:error] preview needs one of cron, every, at or preset", "error") + fires = preview(validate_when(when), datetime.now(timezone.utc), max(1, min(limit, 20))) + return done("[schedule:preview] " + (", ".join(f.isoformat() for f in fires) or "never"), str(len(fires))) + + if action == "approvals": + rows = _approvals().list(status=status or "pending", limit=limit) + if not rows: + return done(f"[schedule:approvals] none {status or 'pending'}", "0") + lines = [f" {r['id']} {r['status']} {r['schedule_name']!r} run={r['run_id']} expires={r['expires_at']}" + for r in rows] + return done(f"[schedule:approvals] {len(rows)} — decide from the Hub, Desk, phone or " + f"`c3 schedule approve `\n" + "\n".join(lines), str(len(rows))) + + if action == "status": + enabled = store.list(enabled=True) + due = sorted(r["next_run_at"] for r in enabled if r.get("next_run_at")) + pending = len(_approvals().list(status="pending")) + return done(f"[schedule:status] enabled={len(enabled)} open_runs={len(store.open_runs())} " + f"pending_approvals={pending} next_due={due[0] if due else '-'} " + f"(the Oracle's tick dispatches; see /api/schedules/status)", "ok") + except RevConflict as exc: + return done(f"[schedule:conflict] {exc}", "conflict") + except ScheduleError as exc: + return done(f"[schedule:error] {exc}", "error") + return done("[schedule:error] unhandled action", "error") diff --git a/tests/test_schedule_mcp.py b/tests/test_schedule_mcp.py new file mode 100644 index 0000000..d6b733a --- /dev/null +++ b/tests/test_schedule_mcp.py @@ -0,0 +1,106 @@ +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from cli.tools import schedule as tool +from services import schedule_approvals as sa +from services import schedule_store as ss + +REPO = Path(__file__).resolve().parents[1] + + +def _finalize(name, args, resp, summ, **kw): + return resp + + +class TestScheduleTool(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + self.store = ss.ScheduleStore(root) + self.approvals = sa.ApprovalStore(root) + self._patches = [patch.object(tool, "_store", return_value=self.store), + patch.object(tool, "_approvals", return_value=self.approvals), + patch.object(tool, "_defaults", return_value={}), + patch("services.schedule_approvals.notify")] + for p in self._patches: + p.start() + self.svc = SimpleNamespace(project_path=str(REPO)) + + def tearDown(self): + for p in self._patches: + p.stop() + self._tmp.cleanup() + + def call(self, action, **kw): + return tool.handle_schedule(action, self.svc, _finalize, **kw) + + def test_build_when_and_target(self): + self.assertEqual(tool.build_when(cron="0 9 * * *", tz="UTC"), {"type": "cron", "expr": "0 9 * * *", "tz": "UTC"}) + self.assertEqual(tool.build_when(preset="weekly", preset_at="08:00", days="mon, fri")["days"], ["mon", "fri"]) + self.assertEqual(tool.build_when(every=600), {"type": "every", "seconds": 600}) + self.assertEqual(tool.build_when(at="2026-10-14T09:00Z")["type"], "at") + self.assertIsNone(tool.build_when()) + self.assertEqual(tool.build_target("skill", "/daily_report weekly x"), {"skill": "/daily_report", "args": "weekly x"}) + self.assertEqual(tool.build_target("command", "echo hi"), {"cmd": "echo hi"}) + self.assertEqual(tool.build_target("prompt", "hello"), {"prompt": "hello"}) + + def test_create_defaults_to_approval_and_project(self): + out = self.call("create", name="rep", target="/daily_report", preset="weekdays") + self.assertIn("[schedule:created] sch-", out) + self.assertIn("asks for approval", out) + row = self.store.list()[0] + self.assertEqual((row["autonomy"], row["project_path"], row["when"]["expr"]), + ("approval", str(REPO), "0 9 * * 1-5")) + + def test_create_requires_a_cadence_and_validates(self): + self.assertIn("needs one of cron", self.call("create", name="x", target="/x")) + self.assertIn("[schedule:error]", self.call("create", name="x", target="/x", every=600, effort="ultra")) + self.assertIn("[schedule:error]", self.call("create", name="x", target="/x", every=600, + permission_mode="bypassPermissions")) + + def test_list_get_update_toggle_delete(self): + self.call("create", name="rep", target="/daily_report", every=3600, autonomy="autonomous") + sid = self.store.list()[0]["id"] + self.assertIn("rep", self.call("list")) + self.assertIn('"id"', self.call("get", schedule_id=sid)) + out = self.call("update", schedule_id=sid, model="haiku", cron="0 8 * * *") + self.assertIn("fields=['model', 'when']", out) + self.assertEqual(self.store.get(sid)["model"], "haiku") + self.assertIn("nothing to change", self.call("update", schedule_id=sid)) + self.assertIn("disabled", self.call("disable", schedule_id=sid)) + self.assertIsNone(self.store.get(sid)["next_run_at"]) + self.assertIn("enabled", self.call("enable", schedule_id=sid)) + self.assertIn("[schedule:deleted]", self.call("delete", schedule_id=sid)) + self.assertIn("[schedule:error]", self.call("delete", schedule_id=sid)) + + def test_run_now_runs_and_approvals(self): + self.call("create", name="rep", target="/daily_report", every=3600) + sid = self.store.list()[0]["id"] + out = self.call("run_now", schedule_id=sid) + self.assertIn("[schedule:queued] run sr-", out) + self.assertIn("needs a human approval", out) + self.assertIn("claimed", self.call("runs")) + self.assertIn("[schedule:runs] no runs", self.call("runs", status="done")) + self.assertIn("none pending", self.call("approvals")) + run = self.store.runs()[0] + self.approvals.create(self.store.get(sid), run, ttl_s=600) + out = self.call("approvals") + self.assertIn("decide from the Hub", out) + self.assertIn("sa-", out) + self.assertIn("pending_approvals=1", self.call("status")) + + def test_preview_and_bad_action(self): + out = self.call("preview", cron="0 9 * * *", tz="UTC", limit=2) + self.assertEqual(out.count("T09:00:00+00:00"), 2) + self.assertIn("action must be one of", self.call("decide")) + + def test_read_actions_are_declared(self): + self.assertTrue(tool.READ_ACTIONS <= set(tool.ACTIONS)) + self.assertNotIn("decide", tool.ACTIONS) + + +if __name__ == "__main__": + unittest.main() From 2e9fa7b1bf43f682daea00822acb0433439d6661 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 13 Oct 2026 10:08:46 -0400 Subject: [PATCH 2/3] feat(hub): Schedules tab; release 2.156.0 The Hub gains a Schedules view: every schedule with run now / pause / edit / delete, a strip of approvals waiting on the user with Approve and Deny, the last 30 runs with duration and cost, and a create/edit modal whose cadence field previews the next fires live. Registered as a persisted main_view and bundled with the other hub components. The guide gets a c3_schedule card and the version moves to 2.156.0 with the changelog entry for the series. Claude-Session: https://claude.ai/code/session_01JUcvmQq81aLo9ewD9Xc4qY --- CHANGELOG.md | 32 +++ cli/guide/tools.html | 45 +++ cli/hub_server.py | 3 +- cli/hub_ui/app.js | 4 +- cli/hub_ui/components/hub_schedules.js | 364 +++++++++++++++++++++++++ cli/hub_ui/components/topbar.js | 2 +- pyproject.toml | 2 +- 7 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 cli/hub_ui/components/hub_schedules.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ab92706..aaa72f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/guide/tools.html b/cli/guide/tools.html index d90a8fa..9a76dd7 100644 --- a/cli/guide/tools.html +++ b/cli/guide/tools.html @@ -107,6 +107,7 @@ 💥 c3_impact ⚡ c3_shell 🕐 c3_shell_job + 📅 c3_schedule 🧪 c3_ci + +
+
+ c3_schedule +
+ execute + v2.156.0 +
+ Scheduled tasks — a skill, prompt or command the Oracle fires on a cadence, autonomously or after a human approves each run +
+
+

A schedule names what to run (kind='skill' with target='/daily_report weekly', a free prompt, or a plain command with no LLM), where (a registered project), how (provider claude / codex / gemini / grok, model, effort, permission_mode, max_budget_usd) and when (cron, preset, every seconds, or a one-shot at). 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 kind=schedule notification the Hub, Desk and phone show. autonomy='approval' (the default from an agent) creates a pending approval instead of firing; nobody approving before the TTL means the slot is logged expired and skipped. Deciding is human-only — the Hub Schedules tab, the Desk, the phone or c3 schedule approve. See docs/scheduled-tasks.md.

+ +

Actions

+ + + + + + + + + +
ActionGroupDescription
createWritename, kind, target, one cadence (cron + tz | preset + preset_at [+ days] | every | at), then provider, model, effort, autonomy, project, cwd, timeout, max_budget_usd, permission_mode, allowed_tools, check_command (exit 1 arms the run, exit 0 skips it for free), overlap, misfire_grace_s
update, enable, disable, deleteWriteschedule_id + changed fields; a disabled schedule keeps its history
run_nowWriteQueue one run outside the cadence; approval-mode schedules still ask
list, get, runs, approvals, statusReadSchedules, one row, recent runs ([schedule_id] [status] [limit]), pending approvals, the loop's state
previewReadA cadence → its next fire times, before you commit to it
+ +

Examples

+
# A weekday report, sonnet at medium effort, asking first
+c3_schedule(action='create', name='daily report', kind='skill',
+            target='/daily_report', preset='weekdays', preset_at='09:00',
+            model='sonnet', effort='medium', max_budget_usd=1.0)
+
+# A free command every hour, no LLM, fires on its own
+c3_schedule(action='create', name='fetch', kind='command',
+            target='git fetch --all --prune', every=3600, autonomy='autonomous')
+
+# See what a cron would do, then what ran
+c3_schedule(action='preview', cron='0 18 * * 1-5', tz='Europe/Berlin')
+c3_schedule(action='runs', limit=10)
+ +

Surfaces: the Hub's Schedules tab (create, edit, pause, run now, approve or deny), /api/mobile/schedules for the Desk and the phone, and c3 schedule on the command line.

+
+
+
diff --git a/cli/hub_server.py b/cli/hub_server.py index 3f0bd17..7f744d0 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -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. @@ -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", diff --git a/cli/hub_ui/app.js b/cli/hub_ui/app.js index 5a869ad..d41369d 100644 --- a/cli/hub_ui/app.js +++ b/cli/hub_ui/app.js @@ -44,7 +44,7 @@ function App() { if (cfg.projects_view === 'grid') setView('grid'); if (cfg.sidebar_collapsed != null) setSidebarCollapsed(!!cfg.sidebar_collapsed); if (cfg.sidebar_group) setFilter(cfg.sidebar_group); - if (['board', 'ci', 'creds', 'tokens', 'locks', 'access', 'enforce', 'sessions'].includes(cfg.main_view)) setMainView(cfg.main_view); + if (['board', 'ci', 'creds', 'tokens', 'locks', 'access', 'enforce', 'sessions', 'schedules'].includes(cfg.main_view)) setMainView(cfg.main_view); } catch { } try { const v = await api.get('/api/version'); setVersion(v.c3_version || ''); } catch { } }, []); @@ -129,6 +129,8 @@ function App() { ) : mainView === 'sessions' ? ( + ) : mainView === 'schedules' ? ( + ) : ( = 3600 ? `${Math.round(when.seconds / 360) / 10}h` : `${Math.round(when.seconds / 60)}m`}`; + if (when.type === 'at') return `once at ${String(when.when || '').slice(0, 16)}`; + if (when.type === 'preset') return `${when.preset} ${when.at || ''}${when.days && when.days.length ? ' ' + when.days.join(',') : ''}`; + return when.expr || '—'; +} + +function schedRelative(iso) { + if (!iso) return '—'; + const diff = (new Date(iso).getTime() - Date.now()) / 1000; + const abs = Math.abs(diff); + const text = abs < 90 ? `${Math.round(abs)}s` : abs < 5400 ? `${Math.round(abs / 60)}m` + : abs < 172800 ? `${Math.round(abs / 360) / 10}h` : `${Math.round(abs / 86400)}d`; + return diff >= 0 ? `in ${text}` : `${text} ago`; +} + +function schedTarget(row) { + const t = row.target || {}; + if (row.kind === 'skill') return `${t.skill || ''} ${t.args || ''}`.trim(); + if (row.kind === 'prompt') return t.prompt || ''; + return t.cmd || ''; +} + +const SCHED_STATUS_COLOR = { + done: 'accent', running: 'blue', dispatched: 'blue', check_running: 'blue', claimed: 'blue', + awaiting_approval: 'warn', approved: 'blue', failed: 'error', timeout: 'error', lost: 'error', + denied: 'warn', expired: 'warn', missed: 'warn', skipped_overlap: 'textDim', check_skipped: 'textDim', + cancelled: 'textDim', +}; + +function SchedStatusPill({ status }) { + const key = SCHED_STATUS_COLOR[status] || 'textMuted'; + return ( + {status || '—'} + ); +} + +function ScheduleModal({ row, projects, onClose, onSaved }) { + const { useState, useEffect } = React; + const editing = !!(row && row.id); + const w = (row && row.when) || {}; + const [f, setF] = useState({ + name: row ? row.name : '', + project_path: row ? row.project_path : ((projects[0] || {}).path || ''), + kind: row ? row.kind : 'skill', + target: row ? schedTarget(row) : '', + cadence: w.type || 'preset', + preset: w.preset || 'weekdays', preset_at: w.at || '09:00', days: (w.days || []).join(','), + cron: w.type === 'cron' ? w.expr : '', every: w.seconds ? Math.round(w.seconds / 60) : 60, + at: w.type === 'at' ? w.when : '', tz: w.tz || 'local', + provider: row ? row.provider : 'claude', model: row ? row.model : '', effort: row ? row.effort : '', + permission_mode: row ? row.permission_mode : 'acceptEdits', + autonomy: row ? row.autonomy : 'approval', + max_budget_usd: row ? row.max_budget_usd : 0, timeout_s: row ? row.timeout_s : 1800, + overlap: row ? row.overlap : 'skip', + grace_s: row && row.misfire ? row.misfire.grace_s : 900, + check_command: row ? row.check_command : '', + allowed_tools: row ? row.allowed_tools : '', + }); + const [preview, setPreview] = useState([]); + const [err, setErr] = useState(''); + const [saving, setSaving] = useState(false); + const set = (k) => (e) => setF({ ...f, [k]: e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e }); + + const when = () => { + if (f.cadence === 'cron') return { type: 'cron', expr: f.cron, tz: f.tz }; + if (f.cadence === 'every') return { type: 'every', seconds: Math.max(1, Number(f.every) || 0) * 60 }; + if (f.cadence === 'at') return { type: 'at', when: f.at }; + return { type: 'preset', preset: f.preset, at: f.preset_at, days: f.days.split(',').map(s => s.trim()).filter(Boolean), tz: f.tz }; + }; + + useEffect(() => { + let alive = true; + api.post('/api/hub/schedules/preview', { when: when(), count: 3 }) + .then(d => { if (alive) { setPreview(d.next || []); setErr(''); } }) + .catch(e => { if (alive) { setPreview([]); setErr(String(e && e.message ? e.message : e)); } }); + return () => { alive = false; }; + }, [f.cadence, f.preset, f.preset_at, f.days, f.cron, f.every, f.at, f.tz]); + + const body = () => { + const target = f.kind === 'command' ? { cmd: f.target } : f.kind === 'prompt' ? { prompt: f.target } + : { skill: f.target.split(' ')[0], args: f.target.split(' ').slice(1).join(' ') }; + return { + name: f.name, project: f.project_path, kind: f.kind, target, when: when(), + provider: f.provider, model: f.model, effort: f.effort, permission_mode: f.permission_mode, + autonomy: f.autonomy, max_budget_usd: Number(f.max_budget_usd) || 0, timeout_s: Number(f.timeout_s) || 1800, + overlap: f.overlap, misfire: { policy: 'grace', grace_s: Number(f.grace_s) || 0 }, + check_command: f.check_command, allowed_tools: f.allowed_tools, + }; + }; + + const save = async () => { + setSaving(true); + try { + if (editing) await api.patch(`/api/hub/schedules/${row.id}`, body()); + else await api.post('/api/hub/schedules', body()); + onSaved(); + } catch (e) { + setErr(String(e && e.message ? e.message : e)); + setSaving(false); + } + }; + + const llm = f.kind !== 'command'; + const sel = (k, opts, labels) => ( + + ); + const two = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }; + + return ( + +
+
Name
+
Project + +
+
+
+
Kind{sel('kind', ['skill', 'prompt', 'command'])}
+
{f.kind === 'skill' ? 'Skill and args' : f.kind === 'prompt' ? 'Prompt' : 'Command'} + +
+
+ + When +
+ {sel('cadence', ['preset', 'cron', 'every', 'at'], ['Preset', 'Cron', 'Every N min', 'Once at'])} + {f.cadence === 'preset' && sel('preset', SCHED_PRESETS)} + {f.cadence === 'preset' && } + {f.cadence === 'cron' && } + {f.cadence === 'every' && } + {f.cadence === 'every' && minutes} + {f.cadence === 'at' && } +
+ {f.cadence === 'preset' && f.preset === 'weekly' && ( + + )} + {(f.cadence === 'preset' || f.cadence === 'cron') && ( + + )} +
+ {preview.length ? 'next: ' + preview.map(p => p.replace('T', ' ').slice(0, 16)).join(' · ') : (err || 'no upcoming fire')} +
+ + {llm && ( +
+
Provider{sel('provider', SCHED_PROVIDERS)}
+
Model
+
Effort{sel('effort', SCHED_EFFORTS)}
+
+ )} +
+
Autonomy{sel('autonomy', ['approval', 'autonomous'], ['Ask me first', 'Autonomous'])}
+ {llm &&
Permission mode{sel('permission_mode', SCHED_MODES)}
} + {llm &&
Budget (USD, 0 = none)
} +
+
+
Timeout (s)
+
Overlap{sel('overlap', ['skip', 'allow'], ['Skip if still running', 'Allow'])}
+
Misfire grace (s)
+
+ Check command (optional — exit 1 arms the run, exit 0 skips it) + + {llm && f.provider === 'claude' && ( +
+ Allowed tools (optional) + +
+ )} + {err && preview.length > 0 &&
{err}
} + + + + +
+ ); +} + +function HubSchedules({ projects, onOpenDrill }) { + const { useState, useCallback } = React; + const [rows, setRows] = useState(null); + const [runs, setRuns] = useState([]); + const [approvals, setApprovals] = useState([]); + const [status, setStatus] = useState(null); + const [err, setErr] = useState(''); + const [modal, setModal] = useState(null); + const [confirm, setConfirm] = useState(null); + + const load = useCallback(async () => { + try { + const [s, r, a, st] = await Promise.all([ + api.get('/api/hub/schedules'), api.get('/api/hub/schedules/runs?limit=30'), + api.get('/api/hub/schedules/approvals?status=pending'), api.get('/api/hub/schedules/status'), + ]); + setRows(s.schedules || []); setRuns(r.runs || []); setApprovals(a.approvals || []); setStatus(st); + setErr(''); + } catch (e) { + setErr(String(e && e.message ? e.message : e)); + } + }, []); + React.useEffect(() => { load(); }, [load]); + usePoll(load, 5000); + + const act = async (fn) => { + try { await fn(); } catch (e) { setErr(String(e && e.message ? e.message : e)); } + load(); + }; + const decide = (id, decision) => act(() => api.post(`/api/hub/schedules/approvals/${id}`, { decision })); + const toggle = (row) => act(() => api.post(`/api/hub/schedules/${row.id}/${row.enabled ? 'disable' : 'enable'}`, {})); + const runNow = (row) => act(() => api.post(`/api/hub/schedules/${row.id}/run`, {})); + const remove = (row) => act(() => api.del(`/api/hub/schedules/${row.id}`)); + + const sched = status || {}; + const schedState = !status ? 'loading' : status.reachable === false ? 'oracle unreachable' + : sched.standby ? 'standby' : sched.running ? 'running' : 'stopped'; + const schedColor = schedState === 'running' ? T.accent : schedState === 'loading' ? T.textMuted : T.warn; + const grid = 'minmax(0,1.6fr) minmax(0,1fr) minmax(0,1.8fr) minmax(0,1.3fr) minmax(0,1fr) 90px 150px'; + const btn = (label, onClick, color, title) => ( + {label} + ); + + return ( +
+
+ + Scheduled tasks + scheduler {schedState}{sched.next_due ? ` · next ${schedRelative(sched.next_due)}` : ''} + + {rows ? `${rows.length} schedule(s)` : 'loading…'} + +
+ +
+ +
+
+ + {err && ( +
+ {err} +
+ )} + + {approvals.length > 0 && ( +
+
Waiting for your approval
+ {approvals.map(a => ( +
+ {a.schedule_name} + {a.summary} + expires {schedRelative(a.expires_at)} + + +
+ ))} +
+ )} + + {rows && rows.length === 0 && ( +
+ No scheduled tasks yet. Create one here, or from a session with c3_schedule(action='create', …). +
+ )} + + {rows && rows.length > 0 && ( +
+
+ NAMEPROJECTRUNSWHEN · NEXTMODELLAST +
+ {rows.map(row => ( +
+ + {row.name} + + {row.autonomy === 'autonomous' ? 'auto' : 'ask'} + + + onOpenDrill && onOpenDrill(projects.find(p => p.path === row.project_path) || { path: row.project_path, name: row.project_name })} + style={{ color: T.textMuted, cursor: onOpenDrill ? 'pointer' : 'default', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {row.project_name || row.project_path} + + + {row.kind === 'command' ? '$ ' : ''}{schedTarget(row)} + + + {schedWhenLabel(row.when)} · {row.enabled ? schedRelative(row.next_run_at) : 'paused'} + + + {row.kind === 'command' ? '—' : `${row.provider}${row.model ? '/' + row.model : ''}${row.effort ? ' ' + row.effort : ''}`} + + + + {btn('Run', () => runNow(row), T.accent, 'Queue a run now')} + {btn(row.enabled ? 'Pause' : 'Resume', () => toggle(row))} + {btn('Edit', () => setModal(row))} + {btn('Delete', () => setConfirm(row), T.error)} + +
+ ))} +
+ )} + + {runs.length > 0 && ( +
+
Recent runs
+ {runs.map(run => ( +
+ + {run.schedule_name} + {schedRelative(run.claimed_at)} · {run.trigger} + {run.duration_ms != null ? `${(run.duration_ms / 1000).toFixed(1)}s` : ''} + {run.cost_usd != null ? `$${Number(run.cost_usd).toFixed(3)}` : ''} + + {run.error || run.result_excerpt || ''} + +
+ ))} +
+ )} + + {modal && ( + setModal(null)} + onSaved={() => { setModal(null); load(); }} /> + )} + {confirm && ( + { remove(confirm); setConfirm(null); }} onCancel={() => setConfirm(null)} /> + )} +
+ ); +} diff --git a/cli/hub_ui/components/topbar.js b/cli/hub_ui/components/topbar.js index 606d894..c37f81c 100644 --- a/cli/hub_ui/components/topbar.js +++ b/cli/hub_ui/components/topbar.js @@ -38,7 +38,7 @@ function TopBar({ version, activeCount, darkMode, hubConfig, mainView, setMainVi display: 'inline-flex', marginLeft: 10, border: `1px solid ${T.border}`, borderRadius: 6, overflow: 'hidden', }}> - {[['projects', 'Projects', 'layers'], ['board', 'Tasks', 'check'], ['ci', 'CI', 'play'], ['creds', 'Credentials', 'lock'], ['tokens', 'Tokens', 'gauge'], ['locks', 'Locks', 'gitBranch'], ['access', 'Access', 'eye'], ['enforce', 'Discipline', 'shield'], ['sessions', 'Sessions', 'messageSquare']].map(([id, label, icon]) => ( + {[['projects', 'Projects', 'layers'], ['board', 'Tasks', 'check'], ['ci', 'CI', 'play'], ['creds', 'Credentials', 'lock'], ['tokens', 'Tokens', 'gauge'], ['locks', 'Locks', 'gitBranch'], ['access', 'Access', 'eye'], ['enforce', 'Discipline', 'shield'], ['sessions', 'Sessions', 'messageSquare'], ['schedules', 'Schedules', 'clock']].map(([id, label, icon]) => (