code-context-control 2.154.0__py3-none-any.whl → 2.156.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. cli/c3.py +92 -1
  2. cli/commands/parser.py +50 -0
  3. cli/guide/tools.html +45 -0
  4. cli/hook_session_open.py +101 -2
  5. cli/hub_server.py +176 -1
  6. cli/hub_ui/app.js +3 -1
  7. cli/hub_ui/components/hub_schedules.js +364 -0
  8. cli/hub_ui/components/topbar.js +1 -1
  9. cli/mcp_server.py +174 -3
  10. cli/server.py +57 -35
  11. cli/tools/_grants.py +18 -0
  12. cli/tools/edit.py +2 -1
  13. cli/tools/locks.py +1 -1
  14. cli/tools/project.py +18 -0
  15. cli/tools/schedule.py +226 -0
  16. {code_context_control-2.154.0.dist-info → code_context_control-2.156.0.dist-info}/METADATA +2 -1
  17. {code_context_control-2.154.0.dist-info → code_context_control-2.156.0.dist-info}/RECORD +38 -28
  18. core/host.py +13 -3
  19. oracle/config.py +15 -0
  20. oracle/oracle_server.py +24 -0
  21. oracle/services/mobile_api.py +200 -1
  22. oracle/services/schedule_api.py +255 -0
  23. oracle/services/scheduler.py +320 -0
  24. services/agent_locks.py +8 -5
  25. services/cron_expr.py +292 -0
  26. services/host_handoff.py +320 -0
  27. services/notifications.py +3 -1
  28. services/project_manager.py +41 -41
  29. services/schedule_approvals.py +202 -0
  30. services/schedule_runner.py +264 -0
  31. services/schedule_store.py +601 -0
  32. services/session_live.py +16 -4
  33. services/shell_jobs.py +31 -7
  34. services/ui_registry.py +264 -0
  35. {code_context_control-2.154.0.dist-info → code_context_control-2.156.0.dist-info}/WHEEL +0 -0
  36. {code_context_control-2.154.0.dist-info → code_context_control-2.156.0.dist-info}/entry_points.txt +0 -0
  37. {code_context_control-2.154.0.dist-info → code_context_control-2.156.0.dist-info}/licenses/LICENSE +0 -0
  38. {code_context_control-2.154.0.dist-info → code_context_control-2.156.0.dist-info}/top_level.txt +0 -0
cli/c3.py CHANGED
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
91
91
  # Config
92
92
  CONFIG_DIR = ".c3"
93
93
  CONFIG_FILE = ".c3/config.json"
94
- __version__ = "2.154.0"
94
+ __version__ = "2.156.0"
95
95
 
96
96
  # The PreToolUse matcher for native subagent calls (installer and hub migration).
97
97
  AGENT_MATCHER = "Agent|Task"
@@ -264,6 +264,7 @@ _C3_MCP_ALLOW = [
264
264
  "mcp__c3__c3_jira", "mcp__c3__c3_credentials",
265
265
  "mcp__c3__c3_project", "mcp__c3__c3_task", "mcp__c3__c3_artifacts",
266
266
  "mcp__c3__c3_override", "mcp__c3__c3_ci", "mcp__c3__c3_shell_job",
267
+ "mcp__c3__c3_schedule",
267
268
  ]
268
269
 
269
270
  # Obsolete MCP tool names from earlier C3 versions. `c3 permissions clean`
@@ -7235,6 +7236,95 @@ def _enforce_audit(result: dict, project_path: str) -> None:
7235
7236
  _OVERRIDE_DEFAULT_TOOL = {"read": "Read", "write": "Edit"}
7236
7237
 
7237
7238
 
7239
+ def cmd_schedule(args):
7240
+ """`c3 schedule` — scheduled tasks: list, add, run, approve or deny (docs/scheduled-tasks.md)."""
7241
+ import json as _json
7242
+
7243
+ from cli.tools.schedule import build_target, build_when
7244
+ from services.schedule_approvals import ApprovalError, ApprovalStore
7245
+ from services.schedule_store import ScheduleError, ScheduleStore
7246
+
7247
+ sub = getattr(args, "schedule_cmd", None)
7248
+ if not sub:
7249
+ print("Usage: c3 schedule <list|add|show|rm|enable|disable|run|runs|approvals|approve|deny> ...")
7250
+ return
7251
+ store = ScheduleStore()
7252
+ approvals = ApprovalStore()
7253
+ try:
7254
+ if sub == "list":
7255
+ rows = store.list()
7256
+ if not rows:
7257
+ print("No schedules. Add one with `c3 schedule add`.")
7258
+ for row in rows:
7259
+ when = row.get("when") or {}
7260
+ cadence = when.get("expr") or (f"every {when.get('seconds')}s" if when.get("type") == "every"
7261
+ else when.get("when", ""))
7262
+ flag = "on " if row.get("enabled") else "off"
7263
+ print(f" {row['id']} [{flag}] {row['name']!r} {row['kind']} @ {cadence} {row['autonomy']} "
7264
+ f"next={row.get('next_run_at') or '-'} last={row.get('last_status') or '-'} "
7265
+ f"{row['project_path']}")
7266
+ elif sub == "show":
7267
+ row = store.get(args.schedule_id)
7268
+ if row is None:
7269
+ raise ScheduleError(f"no schedule {args.schedule_id}")
7270
+ print(_json.dumps(row, indent=2, ensure_ascii=False))
7271
+ elif sub == "add":
7272
+ from oracle.config import load_config
7273
+ from oracle.services.scheduler import defaults_from_config
7274
+ when = build_when(cron=args.cron, every=args.every, at=args.at, preset=args.preset, tz=args.tz,
7275
+ preset_at=args.preset_at, days=args.days)
7276
+ if when is None:
7277
+ raise ScheduleError("give one of --cron, --every, --at or --preset")
7278
+ fields = {
7279
+ "name": args.name, "kind": args.kind, "target": build_target(args.kind, args.target),
7280
+ "provider": args.provider, "model": args.model, "effort": args.effort,
7281
+ "autonomy": args.autonomy, "project_path": str(Path(args.project_path).resolve()),
7282
+ "when": when, "overlap": args.overlap, "check_command": args.check_command,
7283
+ }
7284
+ if args.timeout_s:
7285
+ fields["timeout_s"] = args.timeout_s
7286
+ if args.max_budget_usd:
7287
+ fields["max_budget_usd"] = args.max_budget_usd
7288
+ if args.permission_mode:
7289
+ fields["permission_mode"] = args.permission_mode
7290
+ row = store.create(fields, defaults=defaults_from_config(load_config()), created_by="cli")
7291
+ print(f"Created {row['id']} {row['name']!r}; next run {row['next_run_at']}")
7292
+ elif sub in ("enable", "disable"):
7293
+ row = store.set_enabled(args.schedule_id, sub == "enable", by="cli")
7294
+ print(f"{row['id']} {sub}d; next run {row.get('next_run_at') or '-'}")
7295
+ elif sub == "rm":
7296
+ if not store.delete(args.schedule_id, by="cli"):
7297
+ raise ScheduleError(f"no schedule {args.schedule_id}")
7298
+ print(f"Deleted {args.schedule_id}")
7299
+ elif sub == "run":
7300
+ if store.get(args.schedule_id) is None:
7301
+ raise ScheduleError(f"no schedule {args.schedule_id}")
7302
+ run = store.request_run(args.schedule_id, trigger="manual", by="cli", approved=args.bypass_approval)
7303
+ print(f"Queued run {run['run_id']} ({run['status']}); the Oracle dispatches it on its next tick")
7304
+ elif sub == "runs":
7305
+ rows = store.runs(schedule_id=args.schedule_id or None, status=args.status or None, limit=args.limit)
7306
+ if not rows:
7307
+ print("No runs.")
7308
+ for run in rows:
7309
+ cost = f" ${run['cost_usd']:.3f}" if run.get("cost_usd") is not None else ""
7310
+ err = f" — {str(run['error'])[:80]}" if run.get("error") else ""
7311
+ print(f" {run['run_id']} {run['status']:<17} {run['trigger']:<8} {run['schedule_name']!r} "
7312
+ f"{(run.get('claimed_at') or '')[:19]}{cost}{err}")
7313
+ elif sub == "approvals":
7314
+ rows = approvals.list(status=args.status or "pending")
7315
+ if not rows:
7316
+ print(f"No {args.status or 'pending'} approvals.")
7317
+ for row in rows:
7318
+ print(f" {row['id']} {row['status']:<8} {row['schedule_name']!r} {row['summary']} "
7319
+ f"expires={row['expires_at']}")
7320
+ elif sub in ("approve", "deny"):
7321
+ row = approvals.decide(args.approval_id, sub, decided_by="cli", note=args.note or "")
7322
+ print(f"{row['id']} {row['status']}; the Oracle acts on it on its next tick")
7323
+ except (ScheduleError, ApprovalError) as exc:
7324
+ print(f"Error: {exc}")
7325
+ sys.exit(1)
7326
+
7327
+
7238
7328
  def cmd_override(args):
7239
7329
  """`c3 override` — Override Requests: allow one blocked call, once.
7240
7330
 
@@ -9663,6 +9753,7 @@ def main():
9663
9753
  "access": cmd_access,
9664
9754
  "enforce": cmd_enforce,
9665
9755
  "override": cmd_override,
9756
+ "schedule": cmd_schedule,
9666
9757
  "locks": cmd_locks,
9667
9758
  "prune-quarantine": cmd_prune_quarantine,
9668
9759
  "ci": cmd_ci,
cli/commands/parser.py CHANGED
@@ -840,6 +840,56 @@ def build_parser(version: str, parse_cli_ide_arg):
840
840
  ov_costs.add_argument("--path", dest="project_path", default=".",
841
841
  help="Project directory (default: current)")
842
842
 
843
+ # ── Scheduled tasks (docs/scheduled-tasks.md) ─────────────────────────
844
+ # The human CLI over the same stores the Oracle fires from. approve/deny
845
+ # live here and on the Hub/Desk/phone, never on the c3_schedule agent tool.
846
+ p_schedule = subparsers.add_parser(
847
+ "schedule", help="Scheduled tasks — skills, prompts or commands the Oracle fires on a cadence")
848
+ sched_subs = p_schedule.add_subparsers(dest="schedule_cmd")
849
+ sched_subs.add_parser("list", help="Every schedule on this machine")
850
+ sc_show = sched_subs.add_parser("show", help="One schedule as JSON")
851
+ sc_show.add_argument("schedule_id")
852
+ sc_add = sched_subs.add_parser("add", help="Create a schedule")
853
+ sc_add.add_argument("name")
854
+ sc_add.add_argument("target", help="'/skill args', a prompt, or a command (see --kind)")
855
+ sc_add.add_argument("--kind", choices=["skill", "prompt", "command"], default="skill")
856
+ sc_add.add_argument("--cron", default="", help="5-field cron, e.g. '0 9 * * 1-5'")
857
+ sc_add.add_argument("--every", type=int, default=0, help="Seconds between runs")
858
+ sc_add.add_argument("--at", default="", help="One-shot ISO datetime")
859
+ sc_add.add_argument("--preset", choices=["hourly", "daily", "weekdays", "weekly", "monthly"], default="")
860
+ sc_add.add_argument("--preset-at", dest="preset_at", default="09:00", help="HH:MM for --preset")
861
+ sc_add.add_argument("--days", default="", help="Weekly preset days, e.g. mon,fri")
862
+ sc_add.add_argument("--tz", default="local", help="IANA zone or 'local'")
863
+ sc_add.add_argument("--provider", choices=["claude", "codex", "gemini", "grok"], default="claude")
864
+ sc_add.add_argument("--model", default="")
865
+ sc_add.add_argument("--effort", choices=["", "low", "medium", "high", "xhigh", "max"], default="")
866
+ sc_add.add_argument("--autonomy", choices=["autonomous", "approval"], default="approval")
867
+ sc_add.add_argument("--permission-mode", dest="permission_mode", default="")
868
+ sc_add.add_argument("--timeout", dest="timeout_s", type=int, default=0)
869
+ sc_add.add_argument("--budget", dest="max_budget_usd", type=float, default=0.0, help="--max-budget-usd cap")
870
+ sc_add.add_argument("--overlap", choices=["skip", "allow"], default="skip")
871
+ sc_add.add_argument("--check", dest="check_command", default="",
872
+ help="Command whose exit 1 arms the run; exit 0 skips it")
873
+ sc_add.add_argument("--path", dest="project_path", default=".", help="Project directory (default: current)")
874
+ for verb, help_text in (("enable", "Resume a schedule"), ("disable", "Pause a schedule"),
875
+ ("rm", "Delete a schedule")):
876
+ sp = sched_subs.add_parser(verb, help=help_text)
877
+ sp.add_argument("schedule_id")
878
+ sc_run = sched_subs.add_parser("run", help="Queue a run now")
879
+ sc_run.add_argument("schedule_id")
880
+ sc_run.add_argument("--bypass-approval", dest="bypass_approval", action="store_true",
881
+ help="Skip the approval gate for this one run")
882
+ sc_runs = sched_subs.add_parser("runs", help="Recent runs")
883
+ sc_runs.add_argument("--schedule", dest="schedule_id", default="")
884
+ sc_runs.add_argument("--status", default="")
885
+ sc_runs.add_argument("--limit", type=int, default=30)
886
+ sc_appr = sched_subs.add_parser("approvals", help="Pending (or --status) approvals")
887
+ sc_appr.add_argument("--status", default="")
888
+ for verb in ("approve", "deny"):
889
+ sp = sched_subs.add_parser(verb, help=f"{verb.capitalize()} one pending scheduled run")
890
+ sp.add_argument("approval_id")
891
+ sp.add_argument("--note", default="")
892
+
843
893
  # ── Tool discipline / enforcement mode (v2.66.0) ────────────────────
844
894
  # LAYER C: how hard C3 pushes the agent toward c3_* tools. Distinct from
845
895
  # `c3 access` (path policy — a security boundary) and from
cli/guide/tools.html CHANGED
@@ -107,6 +107,7 @@
107
107
  <a href="#c3_impact" class="sidebar-link"><span class="icon">💥</span> c3_impact</a>
108
108
  <a href="#c3_shell" class="sidebar-link"><span class="icon">⚡</span> c3_shell</a>
109
109
  <a href="#c3_shell_job" class="sidebar-link"><span class="icon">🕐</span> c3_shell_job</a>
110
+ <a href="#c3_schedule" class="sidebar-link"><span class="icon">📅</span> c3_schedule</a>
110
111
  <a href="#c3_ci" class="sidebar-link"><span class="icon">🧪</span> c3_ci</a>
111
112
  </div>
112
113
  <div class="sidebar-section">
@@ -166,6 +167,7 @@
166
167
  <a href="#c3_impact" class="toc-item">c3_impact <span class="cat">Analysis</span></a>
167
168
  <a href="#c3_shell" class="toc-item">c3_shell <span class="cat">Execute</span></a>
168
169
  <a href="#c3_shell_job" class="toc-item">c3_shell_job <span class="cat">Execute</span></a>
170
+ <a href="#c3_schedule" class="toc-item">c3_schedule <span class="cat">Execute</span></a>
169
171
  <a href="#c3_ci" class="toc-item">c3_ci <span class="cat">Execute</span></a>
170
172
  <a href="#c3_edit" class="toc-item">c3_edit <span class="cat">Write</span></a>
171
173
  <a href="#c3_validate" class="toc-item">c3_validate <span class="cat">Write</span></a>
@@ -1602,6 +1604,49 @@ c3_task(action=<span class="str">'note_add'</span>, note=<span class="str">'Kanb
1602
1604
  </div>
1603
1605
  </div>
1604
1606
 
1607
+ <!-- c3_schedule -->
1608
+ <div class="tool-card" id="c3_schedule">
1609
+ <div class="tool-card-header">
1610
+ <span class="tool-name">c3_schedule</span>
1611
+ <div class="tag-row">
1612
+ <span class="badge badge-purple">execute</span>
1613
+ <span class="badge">v2.156.0</span>
1614
+ </div>
1615
+ <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>
1616
+ </div>
1617
+ <div class="tool-card-body">
1618
+ <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>
1619
+
1620
+ <h4>Actions</h4>
1621
+ <table class="params-table">
1622
+ <thead><tr><th>Action</th><th>Group</th><th>Description</th></tr></thead>
1623
+ <tbody>
1624
+ <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>
1625
+ <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>
1626
+ <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>
1627
+ <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>
1628
+ <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>
1629
+ </tbody>
1630
+ </table>
1631
+
1632
+ <h4>Examples</h4>
1633
+ <pre><code><span class="com"># A weekday report, sonnet at medium effort, asking first</span>
1634
+ c3_schedule(action=<span class="str">'create'</span>, name=<span class="str">'daily report'</span>, kind=<span class="str">'skill'</span>,
1635
+ target=<span class="str">'/daily_report'</span>, preset=<span class="str">'weekdays'</span>, preset_at=<span class="str">'09:00'</span>,
1636
+ model=<span class="str">'sonnet'</span>, effort=<span class="str">'medium'</span>, max_budget_usd=<span class="num">1.0</span>)
1637
+
1638
+ <span class="com"># A free command every hour, no LLM, fires on its own</span>
1639
+ c3_schedule(action=<span class="str">'create'</span>, name=<span class="str">'fetch'</span>, kind=<span class="str">'command'</span>,
1640
+ target=<span class="str">'git fetch --all --prune'</span>, every=<span class="num">3600</span>, autonomy=<span class="str">'autonomous'</span>)
1641
+
1642
+ <span class="com"># See what a cron would do, then what ran</span>
1643
+ c3_schedule(action=<span class="str">'preview'</span>, cron=<span class="str">'0 18 * * 1-5'</span>, tz=<span class="str">'Europe/Berlin'</span>)
1644
+ c3_schedule(action=<span class="str">'runs'</span>, limit=<span class="num">10</span>)</code></pre>
1645
+
1646
+ <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>
1647
+ </div>
1648
+ </div>
1649
+
1605
1650
  <!-- c3_artifacts -->
1606
1651
  <div class="tool-card" id="c3_artifacts">
1607
1652
  <div class="tool-card-header">
cli/hook_session_open.py CHANGED
@@ -30,6 +30,12 @@ the hub, and it skips a project that already has a live UI port, so a
30
30
  ``C3_NO_UI_AUTOSTART=1`` or
31
31
  ``.c3/config.json → {"ui": {"autostart_on_session": false}}``.
32
32
 
33
+ A ``clear`` (or an in-session ``resume``) keeps the MCP server it already
34
+ has, under a host session id that just ended. This hook finds that server
35
+ by the host pid both of them share and hands it the new id
36
+ (``services.host_handoff``, v2.155.0); the server then writes the
37
+ ``C3 connected <new id>`` row the desktop sentinel is waiting for.
38
+
33
39
  ``source: "compact"`` writes NOTHING: a compaction is the same session and
34
40
  the same MCP process, and a "Session started" toast on every compaction
35
41
  would teach the user to ignore the real ones.
@@ -48,6 +54,8 @@ from __future__ import annotations
48
54
  import json
49
55
  import os
50
56
  import sys
57
+ import time
58
+ import uuid
51
59
  from pathlib import Path
52
60
 
53
61
  sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -56,6 +64,11 @@ from cli._hook_utils import detect_host, find_project, log_hook_error # noqa: E
56
64
  from cli.hook_session_end import short_id # noqa: E402
57
65
 
58
66
  _SILENT_SOURCES = {"compact"}
67
+ # How long one session's UI launch holds the project against the next. A
68
+ # server lands in the registry only after it binds its port, a few seconds
69
+ # after Popen; this covers that gap with room to spare. After it, the live
70
+ # registry entry (``_ui_running``) is the guard again.
71
+ _LAUNCH_GRACE_S = 90.0
59
72
 
60
73
 
61
74
  def _ui_autostart_enabled(project: Path) -> bool:
@@ -96,7 +109,10 @@ def _ui_running(pm, project: Path) -> bool:
96
109
  """True when a live UI server is already registered for this project.
97
110
 
98
111
  This is what keeps `resume` / `clear` — both real SessionStart events —
99
- from stacking a second server on every reconnect.
112
+ from stacking a second server on every reconnect. A registered server
113
+ whose port is quiet but whose pid is running is still "running": it is
114
+ starting up, or busy, and a second one would only be the leak this
115
+ check exists to prevent (2.155.0).
100
116
  """
101
117
  target = os.path.normcase(str(project))
102
118
  for entry in pm._read_registry():
@@ -107,8 +123,72 @@ def _ui_running(pm, project: Path) -> bool:
107
123
  same = os.path.normcase(str(Path(raw).resolve())) == target
108
124
  except Exception:
109
125
  same = os.path.normcase(raw) == target
110
- if same and entry.get("port") and pm._port_alive(entry["port"]):
126
+ if not same or not entry.get("port"):
127
+ continue
128
+ if pm._port_alive(entry["port"]):
129
+ return True
130
+ if entry.get("pid") and pm._pid_alive(entry.get("pid")):
131
+ return True
132
+ return False
133
+
134
+
135
+ def _launch_lock(project: Path) -> Path:
136
+ return project / ".c3" / "ui_launch.lock"
137
+
138
+
139
+ def _claim_launch(project: Path) -> bool:
140
+ """Atomically claim the right to launch this project's UI server.
141
+
142
+ ``_ui_running`` only sees a server once it has registered its port, a
143
+ few seconds after launch. Sessions that open inside that window (an
144
+ agent spawning several at once, or a resume racing a startup) each saw
145
+ no live UI and each launched one, which is how a project ended up with
146
+ two servers. The first claimant creates ``.c3/ui_launch.lock`` with
147
+ O_EXCL; anyone else inside ``_LAUNCH_GRACE_S`` stands down.
148
+
149
+ A lock older than the grace window is stale (the launch died, or the
150
+ server since exited). It is taken over by renaming it to a unique name,
151
+ which only one contender can do, then the create is retried. If the
152
+ lock cannot be handled at all the answer is True, which is the
153
+ behaviour from before this lock existed: a missing UI is worse than a
154
+ rare duplicate.
155
+ """
156
+ lock = _launch_lock(project)
157
+ try:
158
+ lock.parent.mkdir(parents=True, exist_ok=True)
159
+ except Exception:
160
+ return True
161
+ for _ in range(3):
162
+ try:
163
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
164
+ except FileExistsError:
165
+ try:
166
+ age = time.time() - lock.stat().st_mtime
167
+ except FileNotFoundError:
168
+ continue # released between our create and stat; retry
169
+ except Exception:
170
+ return True
171
+ if age < _LAUNCH_GRACE_S:
172
+ return False
173
+ stale = lock.with_name(f"ui_launch.stale-{uuid.uuid4().hex}")
174
+ try:
175
+ os.rename(str(lock), str(stale))
176
+ except FileNotFoundError:
177
+ continue # another contender took it over first
178
+ except Exception:
179
+ return False
180
+ try:
181
+ stale.unlink()
182
+ except Exception:
183
+ pass
184
+ continue
185
+ except Exception:
111
186
  return True
187
+ try:
188
+ os.write(fd, f"{os.getpid()} {time.time():.0f}\n".encode("ascii"))
189
+ finally:
190
+ os.close(fd)
191
+ return True
112
192
  return False
113
193
 
114
194
 
@@ -136,6 +216,8 @@ def ensure_ui(project: Path, host_session_id: str = "") -> str:
136
216
  return "unregistered"
137
217
  if _ui_running(pm, project):
138
218
  return "already-running"
219
+ if not _claim_launch(project):
220
+ return "launch-pending"
139
221
  # launch_session copies os.environ into the detached child, which is
140
222
  # where cli/server._register_session reads the ownership from.
141
223
  os.environ["C3_UI_OWNER"] = "session"
@@ -150,6 +232,22 @@ def ensure_ui(project: Path, host_session_id: str = "") -> str:
150
232
  return "error"
151
233
 
152
234
 
235
+ def hand_off_runtime(project: Path, host: str, host_sid: str, start_source: str) -> list:
236
+ """Point the MCP server this host already runs at its new session id.
237
+
238
+ Returns the C3 session ids handed off (usually one, often none: a
239
+ ``startup`` has no server yet). Never raises.
240
+ """
241
+ if not host_sid:
242
+ return []
243
+ try:
244
+ from services.host_handoff import hand_off
245
+ return hand_off(project, host, host_sid, start_source)
246
+ except Exception as exc:
247
+ log_hook_error("hook_session_open", exc)
248
+ return []
249
+
250
+
153
251
  def run(payload: dict, project_path: Path | None = None):
154
252
  """Core logic — importable by the dispatcher and tests. Returns None."""
155
253
  start_source = str(payload.get("source") or "").strip().lower()
@@ -178,6 +276,7 @@ def run(payload: dict, project_path: Path | None = None):
178
276
  kind="session", ref_id=host_sid)
179
277
  except Exception:
180
278
  pass
279
+ hand_off_runtime(project, host, host_sid, start_source)
181
280
  ensure_ui(project, host_sid)
182
281
  return None
183
282
 
cli/hub_server.py CHANGED
@@ -83,7 +83,7 @@ _HUB_CONFIG_DEFAULTS = {
83
83
 
84
84
  # Top-bar tabs a persisted ``main_view`` may name (cli/hub_ui/components/topbar.js).
85
85
  _MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access",
86
- "enforce", "sessions"}
86
+ "enforce", "sessions", "schedules"}
87
87
 
88
88
  # Stock command per IDE id, and whether it takes the project path as an
89
89
  # argument (GUI) instead of being run inside a terminal at that path.
@@ -400,6 +400,7 @@ _HUB_JS_FILES = [
400
400
  "hub_ui/components/hub_enforcement.js",
401
401
  "hub_ui/components/hub_sessions.js",
402
402
  "hub_ui/components/hub_ci.js",
403
+ "hub_ui/components/hub_schedules.js",
403
404
  "hub_ui/components/drill_subprojects.js",
404
405
  "hub_ui/components/drill_health.js",
405
406
  "hub_ui/components/drill_tasks.js",
@@ -3249,6 +3250,180 @@ def _hub_override_audit(action: str, row: dict, *, confirmed: bool = False,
3249
3250
  pass
3250
3251
 
3251
3252
 
3253
+ # ── Scheduled tasks (docs/scheduled-tasks.md) ────────────────────────────
3254
+ # The Oracle owns the loop; the hub reads and writes the same stores on this
3255
+ # machine, so a Hub edit or decision is picked up on the Oracle's next tick.
3256
+
3257
+ def _schedule_api():
3258
+ from oracle.config import load_config as _oracle_config
3259
+ from oracle.services import schedule_api
3260
+ from services.schedule_approvals import ApprovalStore
3261
+ from services.schedule_store import ScheduleStore
3262
+ if schedule_api._get_store() is None:
3263
+ store, approvals = ScheduleStore(), ApprovalStore()
3264
+ schedule_api.configure(get_scheduler=lambda: None, get_store=lambda: store,
3265
+ get_approvals=lambda: approvals, get_scanner=lambda: None,
3266
+ get_cfg=_oracle_config)
3267
+ return schedule_api
3268
+
3269
+
3270
+ def _sched_call(fn, *args, **kwargs):
3271
+ api = _schedule_api()
3272
+ try:
3273
+ return fn(*args, **kwargs), None
3274
+ except api.ApiError as exc:
3275
+ return None, (jsonify({"error": str(exc)}), exc.status)
3276
+ except ValueError as exc:
3277
+ return None, (jsonify({"error": str(exc)}), 400)
3278
+
3279
+
3280
+ def _sched_project_arg(key: str = "path") -> tuple[str, object]:
3281
+ raw = (request.args.get(key) or "").strip()
3282
+ if not raw:
3283
+ return "", None
3284
+ try:
3285
+ return str(_resolve_project_path(raw)), None
3286
+ except ValueError as exc:
3287
+ return "", (jsonify({"error": str(exc)}), 404)
3288
+
3289
+
3290
+ def _sched_body_with_project(body: dict) -> tuple[dict, object]:
3291
+ body = dict(body or {})
3292
+ raw = str(body.pop("project", "") or body.get("project_path") or "").strip()
3293
+ if raw:
3294
+ try:
3295
+ body["project_path"] = str(_resolve_project_path(raw))
3296
+ except ValueError as exc:
3297
+ return body, (jsonify({"error": str(exc)}), 404)
3298
+ return body, None
3299
+
3300
+
3301
+ def _sched_with_names(rows: list) -> list:
3302
+ return [dict(r, project_name=Path(str(r.get("project_path") or "")).name) for r in rows]
3303
+
3304
+
3305
+ @app.route("/api/hub/schedules", methods=["GET"])
3306
+ def api_hub_schedules():
3307
+ project_path, err = _sched_project_arg()
3308
+ if err:
3309
+ return err
3310
+ store = _schedule_api()._get_store()
3311
+ rows = _sched_with_names(store.list(project_path=project_path or None))
3312
+ return jsonify({"schedules": rows, "count": len(rows), "rev": store.rev(), "project": project_path})
3313
+
3314
+
3315
+ @app.route("/api/hub/schedules", methods=["POST"])
3316
+ def api_hub_schedules_create():
3317
+ body, err = _sched_body_with_project(request.get_json(silent=True) or {})
3318
+ if err:
3319
+ return err
3320
+ row, err = _sched_call(_schedule_api().create_schedule, body, scanner=None, by="desktop")
3321
+ return err or (jsonify(row), 201)
3322
+
3323
+
3324
+ @app.route("/api/hub/schedules/status", methods=["GET"])
3325
+ def api_hub_schedules_status():
3326
+ """The Oracle's scheduler status, or ``reachable: false`` when it is down."""
3327
+ base = (_read_hub_config().get("oracle_url") or "").strip() or _oracle_service().url()
3328
+ try:
3329
+ with urllib.request.urlopen(f"{base.rstrip('/')}/api/schedules/status", timeout=2) as resp:
3330
+ data = json.loads(resp.read().decode("utf-8"))
3331
+ except (OSError, ValueError) as exc:
3332
+ return jsonify({"running": False, "reachable": False, "error": str(exc), "oracle_url": base})
3333
+ data["reachable"] = True
3334
+ data["oracle_url"] = base
3335
+ return jsonify(data)
3336
+
3337
+
3338
+ @app.route("/api/hub/schedules/preview", methods=["POST"])
3339
+ def api_hub_schedules_preview():
3340
+ data, err = _sched_call(_schedule_api().preview_when, request.get_json(silent=True) or {})
3341
+ return err or jsonify(data)
3342
+
3343
+
3344
+ @app.route("/api/hub/schedules/runs", methods=["GET"])
3345
+ def api_hub_schedule_runs():
3346
+ project_path, err = _sched_project_arg()
3347
+ if err:
3348
+ return err
3349
+ try:
3350
+ limit = max(1, min(int(request.args.get("limit") or 50), 500))
3351
+ except (TypeError, ValueError):
3352
+ limit = 50
3353
+ rows = _schedule_api()._get_store().runs(
3354
+ schedule_id=(request.args.get("schedule_id") or None),
3355
+ status=((request.args.get("status") or "").strip() or None),
3356
+ project_path=project_path or None, limit=limit)
3357
+ return jsonify({"runs": _sched_with_names(rows), "count": len(rows), "project": project_path})
3358
+
3359
+
3360
+ @app.route("/api/hub/schedules/approvals", methods=["GET"])
3361
+ def api_hub_schedule_approvals():
3362
+ project_path, err = _sched_project_arg()
3363
+ if err:
3364
+ return err
3365
+ rows = _schedule_api()._get_approvals().list(
3366
+ status=((request.args.get("status") or "").strip() or None), project_path=project_path or None)
3367
+ return jsonify({"approvals": _sched_with_names(rows), "count": len(rows)})
3368
+
3369
+
3370
+ @app.route("/api/hub/schedules/approvals/<approval_id>", methods=["POST"])
3371
+ def api_hub_schedule_decide(approval_id):
3372
+ row, err = _sched_call(_schedule_api().decide_approval, str(approval_id),
3373
+ request.get_json(silent=True) or {}, by="desktop")
3374
+ return err or jsonify(row)
3375
+
3376
+
3377
+ @app.route("/api/hub/schedules/<schedule_id>", methods=["GET"])
3378
+ def api_hub_schedule_one(schedule_id):
3379
+ store = _schedule_api()._get_store()
3380
+ row = store.get(schedule_id)
3381
+ if row is None:
3382
+ return jsonify({"error": f"no schedule {schedule_id}"}), 404
3383
+ return jsonify(dict(row, rev=store.rev(), project_name=Path(str(row.get("project_path") or "")).name))
3384
+
3385
+
3386
+ @app.route("/api/hub/schedules/<schedule_id>", methods=["PATCH"])
3387
+ def api_hub_schedule_update(schedule_id):
3388
+ body, err = _sched_body_with_project(request.get_json(silent=True) or {})
3389
+ if err:
3390
+ return err
3391
+ row, err = _sched_call(_schedule_api().update_schedule, schedule_id, body, scanner=None, by="desktop")
3392
+ return err or jsonify(row)
3393
+
3394
+
3395
+ @app.route("/api/hub/schedules/<schedule_id>", methods=["DELETE"])
3396
+ def api_hub_schedule_delete(schedule_id):
3397
+ if not _schedule_api()._get_store().delete(schedule_id, by="desktop"):
3398
+ return jsonify({"error": f"no schedule {schedule_id}"}), 404
3399
+ return jsonify({"deleted": schedule_id})
3400
+
3401
+
3402
+ @app.route("/api/hub/schedules/<schedule_id>/run", methods=["POST"])
3403
+ def api_hub_schedule_run(schedule_id):
3404
+ run, err = _sched_call(_schedule_api().run_schedule, schedule_id, request.get_json(silent=True) or {},
3405
+ by="desktop")
3406
+ return err or (jsonify(run), 202)
3407
+
3408
+
3409
+ @app.route("/api/hub/schedules/<schedule_id>/<action>", methods=["POST"])
3410
+ def api_hub_schedule_toggle(schedule_id, action):
3411
+ if action not in ("enable", "disable"):
3412
+ return jsonify({"error": "unknown action"}), 404
3413
+ row, err = _sched_call(_schedule_api().update_schedule, schedule_id, {"enabled": action == "enable"},
3414
+ scanner=None, by="desktop")
3415
+ return err or jsonify(row)
3416
+
3417
+
3418
+ @app.route("/api/hub/schedules/<schedule_id>/runs", methods=["GET"])
3419
+ def api_hub_schedule_runs_one(schedule_id):
3420
+ store = _schedule_api()._get_store()
3421
+ if store.get(schedule_id) is None:
3422
+ return jsonify({"error": f"no schedule {schedule_id}"}), 404
3423
+ rows = store.runs(schedule_id=schedule_id, status=((request.args.get("status") or "").strip() or None))
3424
+ return jsonify({"runs": rows, "count": len(rows)})
3425
+
3426
+
3252
3427
  @app.route("/api/hub/overrides", methods=["GET"])
3253
3428
  def api_hub_overrides():
3254
3429
  """Pending + recently-decided requests across every project, newest first.
cli/hub_ui/app.js CHANGED
@@ -44,7 +44,7 @@ function App() {
44
44
  if (cfg.projects_view === 'grid') setView('grid');
45
45
  if (cfg.sidebar_collapsed != null) setSidebarCollapsed(!!cfg.sidebar_collapsed);
46
46
  if (cfg.sidebar_group) setFilter(cfg.sidebar_group);
47
- if (['board', 'ci', 'creds', 'tokens', 'locks', 'access', 'enforce', 'sessions'].includes(cfg.main_view)) setMainView(cfg.main_view);
47
+ if (['board', 'ci', 'creds', 'tokens', 'locks', 'access', 'enforce', 'sessions', 'schedules'].includes(cfg.main_view)) setMainView(cfg.main_view);
48
48
  } catch { }
49
49
  try { const v = await api.get('/api/version'); setVersion(v.c3_version || ''); } catch { }
50
50
  }, []);
@@ -129,6 +129,8 @@ function App() {
129
129
  <HubEnforcement projects={projects} onOpenDrill={openDrill} />
130
130
  ) : mainView === 'sessions' ? (
131
131
  <HubSessions projects={projects} />
132
+ ) : mainView === 'schedules' ? (
133
+ <HubSchedules projects={projects} onOpenDrill={openDrill} />
132
134
  ) : (
133
135
  <React.Fragment>
134
136
  <SummaryBar projects={projects} search={search} setSearch={setSearch}