@caddis/cli 0.4.61 → 0.4.62

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.
@@ -28,6 +28,8 @@ import html
28
28
  import json
29
29
  import os
30
30
  import re
31
+ import shutil
32
+ import stat
31
33
  import subprocess
32
34
  import sys
33
35
  import time
@@ -276,6 +278,8 @@ _CSS = _THEME_CSS + _BASE_CSS
276
278
  # (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
277
279
  # shown view gets aria-current. No network.
278
280
  _THEME_SCRIPT = ('<script id="theme-toggle">(function(){var k="caddis-roadmap-theme",d=document.documentElement;try{var t=localStorage.getItem(k);if(t==="light"||t==="dark")d.dataset.theme=t}catch(e){}window.caddisTheme=function(){var c=d.dataset.theme||(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),n=c==="dark"?"light":"dark";d.dataset.theme=n;try{localStorage.setItem(k,n)}catch(e){}};function shown(){var h=location.hash.slice(1),e=h&&document.getElementById(h),v=e&&e.closest(".view");return v?v.id:"inbox"}function cur(){var a=document.querySelectorAll(".nav a.nav-i");for(var i=0;i<a.length;i++){if(a[i].getAttribute("href")==="#"+shown())a[i].setAttribute("aria-current","page");else a[i].removeAttribute("aria-current")}}addEventListener("DOMContentLoaded",cur);setInterval(function(){if(document.getElementById("inbox")&&document.visibilityState==="visible"&&shown()==="inbox")location.reload()},60000);addEventListener("hashchange",function(){var b=document.getElementById("nav-cb");if(b)b.checked=false;cur()})})();</script>')
281
+ # The shared copy is a static page: the inbox reload would only re-request it every minute, so it has no timer.
282
+ _SITE_THEME_SCRIPT = re.sub(r"setInterval\(function\(\)\{.*?\},60000\);", "", _THEME_SCRIPT)
279
283
  _THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
280
284
 
281
285
 
@@ -318,13 +322,14 @@ def _pill(status: str) -> str:
318
322
  return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
319
323
 
320
324
 
321
- def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "") -> str:
325
+ def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "",
326
+ script: str | None = None) -> str:
322
327
  """The app frame around every page: head, left nav, top bar (crumbs and the theme button) and the body.
323
328
  `nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` is plain text."""
324
329
  return "".join([
325
330
  '<!doctype html><html lang="en"><head><meta charset="utf-8">',
326
331
  '<meta name="viewport" content="width=device-width,initial-scale=1">',
327
- f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{_THEME_SCRIPT}</head>",
332
+ f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{script or _THEME_SCRIPT}</head>",
328
333
  f'<body><input type="checkbox" id="nav-cb" class="nav-cb" aria-label="Toggle the navigation menu"><div class="app">{nav}<label for="nav-cb" class="nav-scrim" aria-hidden="true"></label><div class="main">',
329
334
  '<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">☰</label>',
330
335
  f'<div class="crumbs">{crumbs}</div>{_THEME_BUTTON}</header>',
@@ -360,10 +365,12 @@ def _group_rows(rows: list[dict]) -> tuple[list[tuple[str, list[dict]]], list[tu
360
365
  return order, need, count
361
366
 
362
367
 
363
- def _nav(rows: list[dict], base: str) -> str:
368
+ def _nav(rows: list[dict], base: str, site: bool = False) -> str:
364
369
  """The left nav: Needs you (inbox, gates, sessions, runners), then every repo with its roadmaps. Every link opens a
365
370
  view of `index.html` (`#inbox`, `#<rid>`, ...); on the hub itself (`base` "") the link is the bare `#view`. The
366
- active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy."""
371
+ active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy. A `site` (shared copy)
372
+ nav has no Sessions or Runners item, no idle-session note and no Runner sub-link: those show only on the owner's
373
+ machine."""
367
374
  order, _need, count = _group_rows(rows)
368
375
  counts = {"inbox": count, "gates": sum(len(r.get("blockers") or r.get("owner_gates") or []) for r in rows),
369
376
  "sessions": sum(len(_sessions_of(group)) for _f, group in order),
@@ -372,11 +379,13 @@ def _nav(rows: list[dict], base: str) -> str:
372
379
  out = ['<nav class="nav" aria-label="Roadmaps">'
373
380
  '<label for="nav-cb" class="nav-close" aria-label="Close the menu">✕</label>',
374
381
  f'<a class="brand" href="{_e(hub or "#inbox")}">caddis · Roadmaps</a>', '<p class="nav-h">Needs you</p>']
375
- for key, label in (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")):
382
+ for key, label in _NAV_ITEMS:
383
+ if site and key in _LOCAL_VIEWS:
384
+ continue
376
385
  out.append(f'<a class="nav-i" href="{_e(hub)}#{key}">{_e(label)}<span class="n">{_e(counts[key])}</span></a>')
377
386
  out.append('<p class="nav-h">Repos</p>')
378
387
  for folder, group in order:
379
- idle = sum(1 for s in _sessions_of(group) if not s.get("asking"))
388
+ idle = 0 if site else sum(1 for s in _sessions_of(group) if not s.get("asking"))
380
389
  idle_txt = f" · {idle} idle session(s)" if idle else ""
381
390
  out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
382
391
  for r in sorted(group, key=lambda r: str(r["slug"])):
@@ -387,22 +396,29 @@ def _nav(rows: list[dict], base: str) -> str:
387
396
  pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
388
397
  out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}">{_e(r["slug"])}<span class="n">{pct}%</span></a>'
389
398
  f'<div class="nav-sub" data-rid="{rid}">'
390
- + "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS)
399
+ + "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS
400
+ if not (site and anchor in _LOCAL_ANCHORS))
391
401
  + "</div>")
392
402
  out.append("</nav>")
393
403
  return "".join(out)
394
404
 
395
405
 
406
+ _NAV_ITEMS = (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners"))
407
+ _LOCAL_VIEWS = ("sessions", "runners") # views of this machine's live state: a shared copy has neither
408
+ _LOCAL_ANCHORS = ("runner",) # the section of a roadmap view that shows this machine's runner
409
+
396
410
  _ON = "{background:var(--prog-bg);color:var(--prog-fg);font-weight:600}"
397
411
 
398
412
 
399
- def _view_css(rids: list[str]) -> str:
413
+ def _view_css(rids: list[str], site: bool = False) -> str:
400
414
  """Marks the nav item of the view that shows, and opens its sub-links. One rule per roadmap: CSS cannot take
401
415
  the target's id into a selector. Every rid comes from `_rid` (hex and slug characters only), so it is safe
402
416
  in a selector."""
403
417
  shown = ".view:target,.view :target"
404
418
  out = [f'body:not(:has({shown})) .nav a[href="#inbox"]{_ON}']
405
- for key in ("inbox", "gates", "sessions", "runners"):
419
+ for key, _label in _NAV_ITEMS:
420
+ if site and key in _LOCAL_VIEWS:
421
+ continue
406
422
  out.append(f'body:has(#{key}:target,#{key} :target) .nav a[href="#{key}"]{_ON}')
407
423
  for rid in rids:
408
424
  on = f"body:has(#{rid}:target,#{rid} :target)"
@@ -410,11 +426,14 @@ def _view_css(rids: list[str]) -> str:
410
426
  return "".join(out)
411
427
 
412
428
 
413
- def _waiting_section(model: dict) -> str:
429
+ def _waiting_section(model: dict, site: bool = False) -> str:
430
+ """Sessions that wait for input, then the open owner gates. A shared copy (`site`) has the owner gates only: its
431
+ sessions are not on the reader's machine, so a table of them would be empty for a wrong reason."""
414
432
  out = ['<section id="waiting" class="card wait"><h2>Waiting for you</h2>']
415
- sessions, gates = model["sessions"], model["waiting"]
433
+ sessions, gates = ([] if site else model["sessions"]), model["waiting"]
416
434
  if not sessions and not gates:
417
- out.append('<p class="empty">Nothing is waiting for you.</p></section>')
435
+ out.append('<p class="empty">' + ("No owner gate is waiting." if site else "Nothing is waiting for you.")
436
+ + "</p></section>")
418
437
  return "".join(out)
419
438
  # A question or a permission first; a session that only finished its turn and sits idle comes after it.
420
439
  asking = [s for s in sessions if not str(s.get("kind", "")).startswith("idle")]
@@ -645,9 +664,10 @@ def render_html(model: dict) -> str:
645
664
  body=render_body(model))
646
665
 
647
666
 
648
- def render_body(model: dict) -> str:
667
+ def render_body(model: dict, site: bool = False) -> str:
649
668
  """Everything inside `<main>`: the header and the sections. `write_page` keeps it as `<slug>.body.html`; the
650
- hub puts it, with its ids prefixed, in `index.html` as the roadmap's view."""
669
+ hub puts it, with its ids prefixed, in `index.html` as the roadmap's view. A shared copy (`site`) leaves out the
670
+ session tables and the Runner section."""
651
671
  title = f"Roadmap {model['roadmap']}"
652
672
  phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
653
673
  done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
@@ -662,35 +682,41 @@ def render_body(model: dict) -> str:
662
682
  f'milestones · {_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>'
663
683
  f'{commit} · generated {_e(model["generated"])}</p>'
664
684
  + (f'<div class="chips">{chips}</div>' if chips else "") + "</div></header>",
665
- _waiting_section(model), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
666
- _gates(model), _milestones(model), _runner(model),
685
+ _waiting_section(model, site), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
686
+ _gates(model), _milestones(model), "" if site else _runner(model),
687
+ '<p class="empty">Built from roadmap.md and the plan Trackers and headers on origin.</p>' if site else
667
688
  '<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
668
689
  "session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
669
690
 
670
691
 
671
- def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
692
+ def default_page_path(root: Path, slug: str, home: Path | None = None, base: Path | None = None) -> Path:
672
693
  """`~/.caddis/roadmap-pages/<repo folder>-<6 hex>/<slug>.html`: outside every checkout, so a render never makes a
673
694
  repo dirty (17 Oct: the in-repo `.local/` folder is not gitignored in every repo). The folder is the MAIN
674
- checkout (a worktree shares its page); the 6 hex of its path keep two clones with one name apart."""
695
+ checkout (a worktree shares its page); the 6 hex of its path keep two clones with one name apart. `base` (the
696
+ shared copy's build folder) stands in for the pages root."""
675
697
  main = main_checkout(root)
676
698
  tag = hashlib.sha1(str(main).lower().encode("utf-8")).hexdigest()[:6]
677
- return pages_root(home) / f"{main.name}-{tag}" / f"{slug}.html"
699
+ return (base if base is not None else pages_root(home)) / f"{main.name}-{tag}" / f"{slug}.html"
678
700
 
679
701
 
680
- def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
702
+ def write_page(root: Path, slug: str, out: Path | None, roadmap_mod, *, site_dir: Path | None = None) -> Path | None:
681
703
  """At the default path: `<slug>.body.html` (the page body, which the hub shows as a view of `index.html`), a
682
704
  one-line `<slug>.html` that opens that view, and `<slug>.json` (the one-row summary the hub is built from). An
683
705
  `--out` page is a one-off self-contained copy and stays out of the hub. None: a newer caddis wrote this page,
684
- so it is kept."""
685
- path = Path(out) if out else default_page_path(root, slug)
686
- if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
706
+ so it is kept. `site_dir` (the shared copy's build folder) writes there instead, with no version gate, and
707
+ leaves this machine's waiting sessions and runner out of the model before anything is rendered."""
708
+ path = Path(out) if out else default_page_path(root, slug, base=site_dir)
709
+ if out is None and site_dir is None and _owned_by_newer(slug, path.with_suffix(".json")): # a skip does no work
687
710
  return None
688
711
  model = build(root, slug, roadmap_mod)
712
+ if site_dir is not None: # a CI run has no session marks and no runner: hide them, never show them as empty
713
+ model["sessions"] = []
714
+ model["runner"] = {"lock": None, "stale_lock": None, "last_stop": None, "errors": []}
689
715
  path.parent.mkdir(parents=True, exist_ok=True)
690
716
  if out is not None:
691
717
  _write_atomic(path, render_html(model))
692
718
  return path
693
- _write_atomic(path.with_suffix(".body.html"), render_body(model))
719
+ _write_atomic(path.with_suffix(".body.html"), render_body(model, site=site_dir is not None))
694
720
  rid = _rid(path.parent.name, model["roadmap"])
695
721
  _write_atomic(path, _stub(rid, model["roadmap"]) if rid else render_html(model))
696
722
  try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
@@ -733,17 +759,45 @@ def is_newer(theirs, mine) -> bool:
733
759
 
734
760
 
735
761
  GENERATOR_OWNERSHIP_S = 1800 # a newer caddis owns a page only while it keeps writing it (review of #71)
762
+ # While the newer version is still INSTALLED on this machine, it owns the page much longer: an older session that is
763
+ # still open must not undo a release (20 Oct 2026: a 1.3.145 session re-rendered the 1.3.147 hub in the old layout
764
+ # 30 minutes after the release). A real downgrade removes the newer version, and the 30 minutes apply again.
765
+ INSTALLED_OWNERSHIP_S = 24 * 3600
766
+
767
+
768
+ def installed_versions(home: Path | None = None) -> set[str]:
769
+ """The caddis versions installed on this machine: the Claude Code plugin cache folders and agy's plugin
770
+ manifest. Empty when none can be read."""
771
+ base = Path(home or Path.home())
772
+ found: set[str] = set()
773
+ try:
774
+ for d in (base / ".claude" / "plugins" / "cache" / "caddis" / "caddis").iterdir():
775
+ if d.is_dir() and _version_key(d.name) is not None:
776
+ found.add(d.name)
777
+ except OSError:
778
+ pass
779
+ data, _ = _read_json(base / ".gemini" / "config" / "plugins" / "caddis" / "plugin.json")
780
+ if isinstance(data, dict) and _version_key(data.get("version")) is not None:
781
+ found.add(str(data["version"]))
782
+ return found
783
+
784
+
785
+ def _ownership_s(theirs: str, home: Path | None = None) -> int:
786
+ """How long a newer caddis owns what it wrote: a day while it is installed under `home`, else
787
+ GENERATOR_OWNERSHIP_S."""
788
+ return INSTALLED_OWNERSHIP_S if theirs in installed_versions(home) else GENERATOR_OWNERSHIP_S
736
789
 
737
790
 
738
791
  def _newer_owner(summary: Path) -> tuple[str, bool]:
739
- """(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it in
740
- the last GENERATOR_OWNERSHIP_S. An unreadable time is not live: a downgrade must never freeze a page."""
792
+ """(version, live): the newer caddis that wrote `summary` ("" when it is not newer), and whether it wrote it
793
+ within its ownership window (`_ownership_s`). An unreadable time is not live: a downgrade must never freeze a
794
+ page."""
741
795
  data, _ = _read_json(summary)
742
796
  theirs = str((data or {}).get("generator") or "")
743
797
  if not is_newer(theirs, generator_version()):
744
798
  return "", False
745
799
  age_h = _age_h(str((data or {}).get("generated_utc") or ""), datetime.datetime.now(datetime.timezone.utc))
746
- return theirs, age_h is not None and age_h * 3600 <= GENERATOR_OWNERSHIP_S
800
+ return theirs, age_h is not None and age_h * 3600 <= _ownership_s(theirs)
747
801
 
748
802
 
749
803
  def _gate(label: str, theirs: str, live: bool) -> bool:
@@ -753,7 +807,7 @@ def _gate(label: str, theirs: str, live: bool) -> bool:
753
807
  return True
754
808
  if theirs:
755
809
  print(f"[ROADMAP] {label}: taking over from caddis {theirs} (it has not written for "
756
- f"{GENERATOR_OWNERSHIP_S // 60} min)")
810
+ f"{_ownership_s(theirs) // 60} min)")
757
811
  return False
758
812
 
759
813
 
@@ -943,26 +997,121 @@ def _int(value) -> int:
943
997
  return 0
944
998
 
945
999
 
1000
+ def _roadmap_link(r: dict, label=None) -> str:
1001
+ """The roadmap's name linked to its view; plain text for a row that has no view."""
1002
+ rid = _rid(r.get("folder"), r.get("slug"))
1003
+ text = _e(r.get("slug") if label is None else label)
1004
+ return f'<a href="#{rid}">{text}</a>' if rid else text
1005
+
1006
+
1007
+ def _repo_slug_key(r: dict) -> tuple[str, str, str]:
1008
+ return (str(r.get("repo", "")).lower(), str(r.get("folder", "")), str(r.get("slug", "")))
1009
+
1010
+
1011
+ def _table(head: list[str], body: list[str]) -> str:
1012
+ return ('<div class="scroll"><table><tr>' + "".join(f"<th>{_e(h)}</th>" for h in head) + "</tr>"
1013
+ + "".join(body) + "</table></div>")
1014
+
1015
+
1016
+ def _gates_view(rows: list[dict]) -> str:
1017
+ items = []
1018
+ for r in rows:
1019
+ for b in r.get("blockers") or r.get("owner_gates") or []: # a v1 summary has owner_gates only
1020
+ items.append((r, b))
1021
+ items.sort(key=lambda rb: (str(rb[1].get("type") or "owner-gate") != "owner-gate", *_repo_slug_key(rb[0]),
1022
+ str(rb[1].get("milestone", ""))))
1023
+ body = []
1024
+ for r, b in items:
1025
+ rid, ms = _rid(r.get("folder"), r.get("slug")), str(b.get("milestone", ""))
1026
+ ms_text = _e(ms)
1027
+ if rid and re.fullmatch(r"[A-Za-z][\w-]*", ms): # the id is prefixed by `_prefix_ids` only when it is plain
1028
+ ms_text = f'<a href="#{rid}-ms-{ms_text}">{ms_text}</a>'
1029
+ kind = str(b.get("type") or "owner-gate")
1030
+ body.append(f'<tr><td><strong>{_e(r.get("repo"))}</strong></td><td>{_roadmap_link(r)}</td><td>{ms_text}</td>'
1031
+ f'<td><span class="kind k-{_e(kind)}">{_e(kind)}</span></td><td>{_e(b.get("reason"))}</td>'
1032
+ f'<td><code>{_e(b.get("check") or "-")}</code></td></tr>')
1033
+ if not body:
1034
+ return '<p class="empty">No open gates or blockers.</p>'
1035
+ return _table(["Repo", "Roadmap", "Milestone", "Type", "Reason", "Check"], body)
1036
+
1037
+
1038
+ def _sessions_view(rows: list[dict]) -> str:
1039
+ out = []
1040
+ for _folder, group in _group_rows(rows)[0]:
1041
+ sessions = sorted(_sessions_of(group), key=lambda s: not s.get("asking")) # asking first, then by mark order
1042
+ if not sessions:
1043
+ continue
1044
+ body = []
1045
+ for s in sessions:
1046
+ stale = " (stale?)" if s.get("stale") else ""
1047
+ body.append(f'<tr><td><code>{_e(s.get("session"))}</code></td>'
1048
+ f'<td>{"asking" if s.get("asking") else "idle"}</td><td>{_e(s.get("message"))}</td>'
1049
+ f'<td>{_e(s.get("since"))}{_e(stale)}</td></tr>')
1050
+ out.append(f'<div class="card"><h3>{_e(group[0].get("repo"))}</h3>'
1051
+ + _table(["Session", "Asking or idle", "Message", "Since"], body) + "</div>")
1052
+ return "".join(out) or '<p class="empty">No session is waiting.</p>'
1053
+
1054
+
1055
+ def _runners_view(rows: list[dict]) -> str:
1056
+ body = []
1057
+ for r in sorted(rows, key=lambda r: (r.get("runner") != "running", *_repo_slug_key(r))):
1058
+ detail = r.get("runner_detail") if isinstance(r.get("runner_detail"), dict) else {}
1059
+ lock, stop = detail.get("lock"), detail.get("last_stop")
1060
+ batch = lock.get("batch") if isinstance(lock, dict) else None
1061
+ stop_text = " · ".join(str(v) for v in ((stop or {}).get("reason"), (stop or {}).get("utc")) if v) if isinstance(stop, dict) else ""
1062
+ runner = str(r.get("runner") or "")
1063
+ pill = ("s-in-progress" if runner == "running" else "s-blocked" if runner else "s-todo")
1064
+ body.append(f'<tr><td><strong>{_e(r.get("repo"))}</strong></td><td>{_roadmap_link(r)}</td>'
1065
+ f'<td><span class="pill {pill}">{_e(runner or "idle")}</span></td><td>{_e(batch or "-")}</td>'
1066
+ f'<td>{_e(stop_text or "-")}</td><td>{_e(r.get("next") or "-")}</td></tr>')
1067
+ if not body:
1068
+ return '<p class="empty">No roadmap pages yet.</p>'
1069
+ return _table(["Repo", "Roadmap", "Runner", "Batch", "Last stop", "Next"], body)
1070
+
1071
+
1072
+ _VIEWS = {"gates": ("Gates", "Every open owner gate and blocker, across all repos. Owner gates come first.", _gates_view),
1073
+ "sessions": ("Sessions", "The newest session marks of each repo.", _sessions_view),
1074
+ "runners": ("Runners", "The roadmap runner of every roadmap on this machine.", _runners_view)}
1075
+
1076
+
1077
+ def render_view(kind: str, rows: list[dict]) -> str:
1078
+ """`<section class="view" id="gates|sessions|runners">`: a cross-repo table over the page summaries. These sections
1079
+ sit outside any roadmap body, so their ids are not prefixed; links into a roadmap use its prefixed ids."""
1080
+ title, note, fn = _VIEWS[kind]
1081
+ return f'<section class="view" id="{kind}"><h2>{_e(title)}</h2><p class="sub">{_e(note)}</p>{fn(rows)}</section>'
1082
+
1083
+
1084
+ _SITE_NOTE = "Shared copy — built by CI. Live sessions and runners show only on the owner's machine."
1085
+ _SITE_CSS = (".site-banner{margin:0 0 12px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;"
1086
+ "border-left:4px solid var(--accent);background:var(--panel2);color:var(--fg);font-size:13px}")
1087
+
1088
+
946
1089
  def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
947
- bodies: dict[str, str] | None = None) -> str:
1090
+ bodies: dict[str, str] | None = None, *, site: bool = False, banner: str = "") -> str:
948
1091
  """The whole app in one file: the inbox and overview view, then one view per roadmap (its body from `bodies`,
949
1092
  ids prefixed). CSS shows one view at a time, so the nav is the same for every view and is never stale. While the
950
- inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders."""
1093
+ inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders. A shared copy
1094
+ (`site`, built by CI) shows `banner` and leaves out everything of this machine: the sessions and the runners,
1095
+ in the nav, the inbox and the views. Owner gates stay: they come from git."""
951
1096
  bodies = bodies or {}
952
1097
  errors = list(errors)
1098
+ if site: # whatever a summary holds of live state is dropped here too, not only at the writer
1099
+ rows = [{**r, "sessions": [], "runner": "", "runner_detail": {}} for r in rows]
953
1100
  order, need, count = _group_rows(rows)
954
1101
  phases_done = sum(_int(r.get("phases_done")) for r in rows)
955
1102
  phases_total = sum(_int(r.get("phases_total")) for r in rows)
956
1103
  live = sum(1 for r in rows if r.get("runner") == "running")
957
1104
  blocked = sum(_int(r.get("blocked")) for r in rows)
958
- out = ['<section class="view" id="inbox"><section class="kpis">',
1105
+ out = [f'<p class="site-banner" role="note">{_e(banner or _SITE_NOTE)}</p>' if site else "",
1106
+ '<section class="view" id="inbox"><section class="kpis">',
959
1107
  f'<div class="kpi"><b>{_e(count)}</b>Needs you</div>',
960
1108
  f'<div class="kpi"><b>{_e(phases_done)} / {_e(phases_total)}</b>Phases done'
961
1109
  f'{_bar(phases_done, phases_total)}</div>',
962
1110
  f'<div class="kpi"><b>{_e(blocked)}</b>Blocked phases</div>',
963
- f'<div class="kpi"><b>{_e(live)}</b>Runners live</div></section>',
1111
+ "" if site else f'<div class="kpi"><b>{_e(live)}</b>Runners live</div>', "</section>",
964
1112
  f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
965
- f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · the inbox reloads every minute</p>',
1113
+ f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))}'
1114
+ + ("" if site else " · the inbox reloads every minute") + "</p>",
966
1115
  '<section id="needs" class="card wait"><h2>Needs you</h2>']
967
1116
  if not need:
968
1117
  out.append('<p class="empty">Nothing is waiting for you.</p>')
@@ -997,13 +1146,13 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
997
1146
  f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
998
1147
  f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
999
1148
  f'<div class="dots">{dots}</div>'
1000
- f'<div class="empty">next: {_e(r.get("next") or "-")} · runner: {_e(r.get("runner") or "idle")}'
1001
- f'{_e(old)}</div></a>')
1149
+ f'<div class="empty">next: {_e(r.get("next") or "-")}'
1150
+ + ("" if site else f' · runner: {_e(r.get("runner") or "idle")}') + f'{_e(old)}</div></a>')
1002
1151
  out.append("</div>")
1003
1152
  for err in errors:
1004
1153
  out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
1005
- out.append('<p class="empty">Built from the page summaries in this folder. Every roadmap page render rebuilds it.'
1006
- "</p></section>")
1154
+ out.append('<p class="empty">' + ("Built by CI from the roadmaps on origin." if site else
1155
+ "Built from the page summaries in this folder. Every roadmap page render rebuilds it.") + "</p></section>")
1007
1156
  rids = []
1008
1157
  for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
1009
1158
  rid = _rid(r.get("folder"), r.get("slug"))
@@ -1016,27 +1165,33 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
1016
1165
  "in its repo.</p>")
1017
1166
  out.append(f'<section class="view" id="{rid}"><p class="view-crumbs"><a href="#inbox">Roadmaps</a> › '
1018
1167
  f'{_e(r.get("repo"))} › <strong>{_e(r["slug"])}</strong></p>{inner}</section>')
1019
- for key, label in (("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")): # Phase 6 fills these
1020
- out.append(f'<section class="view" id="{key}"><h2>{label}</h2><p class="card empty">The cross-repo {key} '
1021
- 'view comes in a later update. For now, see <a href="#needs">Needs you</a> on the inbox and each '
1022
- "roadmap's own page.</p></section>")
1023
- return _shell(title="Roadmaps", base="", nav=_nav(rows, ""), crumbs="Roadmaps", body="".join(out),
1024
- css=_view_css(rids))
1168
+ for kind in _VIEWS:
1169
+ if not (site and kind in _LOCAL_VIEWS):
1170
+ out.append(render_view(kind, rows))
1171
+ return _shell(title="Roadmaps", base="", nav=_nav(rows, "", site), crumbs="Roadmaps", body="".join(out),
1172
+ css=_view_css(rids, site) + (_SITE_CSS if site else ""),
1173
+ script=_SITE_THEME_SCRIPT if site else None)
1025
1174
 
1026
1175
 
1027
- def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
1176
+ def write_hub(home: Path | None = None, now: datetime.datetime | None = None, *,
1177
+ site_dir: Path | None = None, banner: str = "") -> Path | None:
1028
1178
  """`~/.caddis/roadmap-pages/index.html`: every roadmap page on this machine, grouped per repo. None: a newer
1029
- caddis wrote the hub (its version is in `.hub-generator`), so it is kept."""
1030
- root_dir = pages_root(home)
1179
+ caddis wrote the hub (its version is in `.hub-generator`), so it is kept. `site_dir` (the shared copy's build
1180
+ folder) is written instead, with `banner`, with no live sessions or runners, and with no version gate and no
1181
+ stamp: CI is the only writer of that folder."""
1182
+ root_dir = pages_root(home) if site_dir is None else Path(site_dir)
1031
1183
  root_dir.mkdir(parents=True, exist_ok=True)
1032
1184
  stamp = root_dir / ".hub-generator"
1033
- try:
1034
- theirs = stamp.read_text(encoding="utf-8").strip()
1035
- live = time.time() - stamp.stat().st_mtime <= GENERATOR_OWNERSHIP_S
1036
- except (OSError, UnicodeDecodeError):
1037
- theirs, live = "", False
1038
- if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
1039
- return None
1185
+ if site_dir is None:
1186
+ try:
1187
+ theirs = stamp.read_text(encoding="utf-8").strip()
1188
+ if not is_newer(theirs, generator_version()):
1189
+ theirs = ""
1190
+ live = bool(theirs) and time.time() - stamp.stat().st_mtime <= _ownership_s(theirs, home)
1191
+ except (OSError, UnicodeDecodeError):
1192
+ theirs, live = "", False
1193
+ if _gate("hub", theirs, live):
1194
+ return None
1040
1195
  try:
1041
1196
  install_assets(root_dir)
1042
1197
  except Exception as exc: # a missing font file must never stop the hub
@@ -1048,6 +1203,103 @@ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) ->
1048
1203
  print(f"[ROADMAP] page bodies not read: {exc}", file=sys.stderr)
1049
1204
  bodies = {}
1050
1205
  path = root_dir / "index.html"
1051
- _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc), bodies))
1206
+ now = now or datetime.datetime.now(datetime.timezone.utc)
1207
+ if site_dir is not None:
1208
+ _write_atomic(path, render_hub(rows, errors, now, bodies, site=True, banner=banner))
1209
+ return path
1210
+ _write_atomic(path, render_hub(rows, errors, now, bodies))
1052
1211
  _write_atomic(stamp, generator_version() + "\n")
1053
1212
  return path
1213
+
1214
+
1215
+ # ---- the shared copy: `render --site <dir>` builds in <dir>/build and publishes an allow-list to <dir>/public -----
1216
+
1217
+ def _short_sha(root: Path) -> str:
1218
+ try:
1219
+ done = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=root, capture_output=True, text=True,
1220
+ stdin=subprocess.DEVNULL, timeout=15,
1221
+ creationflags=0x08000000 if os.name == "nt" else 0)
1222
+ except (OSError, subprocess.SubprocessError):
1223
+ return "unknown"
1224
+ return done.stdout.strip() or "unknown" if done.returncode == 0 else "unknown"
1225
+
1226
+
1227
+ def site_banner(root: Path, now: datetime.datetime | None = None, env=None) -> str:
1228
+ """The shared copy's banner (plain text; `render_hub` escapes it). CI names the repo and commit in
1229
+ `GITHUB_REPOSITORY` and `GITHUB_SHA`; on a local run they are the checkout's folder name and `git rev-parse`."""
1230
+ env = os.environ if env is None else env
1231
+ repo = (env.get("GITHUB_REPOSITORY") or "").strip() or main_checkout(Path(root)).name
1232
+ sha = (env.get("GITHUB_SHA") or "").strip()[:7] or _short_sha(Path(root))
1233
+ when = (now or datetime.datetime.now(datetime.timezone.utc)).strftime("%Y-%m-%d %H:%M UTC")
1234
+ return (f"Shared copy — built by CI from {repo} at {sha}, {when}. "
1235
+ "Live sessions and runners show only on the owner's machine.")
1236
+
1237
+
1238
+ def _is_link(path: Path) -> bool:
1239
+ """A symlink or a Windows junction: never followed by the export."""
1240
+ if path.is_symlink():
1241
+ return True
1242
+ try:
1243
+ return bool(os.lstat(path).st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT) # Windows only
1244
+ except (AttributeError, OSError):
1245
+ return False
1246
+
1247
+
1248
+ def _plain_file(path: Path) -> bool:
1249
+ return path.is_file() and not _is_link(path)
1250
+
1251
+
1252
+ def export_site(build: Path, public: Path) -> list[str]:
1253
+ """Copy the allow-list of `build` to a fresh `public` and return the published paths (sorted, `/` separated):
1254
+ `index.html`, every file under `_assets/` except `*.json`, `*.body.html` and dot files, and each
1255
+ `<folder>/<slug>.html` whose `<slug>.json` sits beside it and whose slug passes the slug rule. Nothing else is
1256
+ published: not a `*.json` (the local checkout path and session data), not a `*.body.html`, not `.locks/`, not
1257
+ `.hub-generator`, not a dot file or folder, and not a link. `public` is wiped first, but only a folder this
1258
+ function made (a marker beside it says so), or an empty one; a link, a file, `build` itself and a folder that
1259
+ holds `build` or sits in it are refused (ValueError)."""
1260
+ build, public = Path(build), Path(public)
1261
+ if public.name in ("", ".", ".."):
1262
+ raise ValueError(f"the public folder needs a plain folder name, not {str(public)!r}")
1263
+ if not build.is_dir() or _is_link(build):
1264
+ raise ValueError(f"the build folder is not a plain folder: {build}")
1265
+ b = build.resolve()
1266
+ p = public.parent.resolve() / public.name
1267
+ if _is_link(public) or (public.exists() and not public.is_dir()):
1268
+ raise ValueError(f"the public folder is a link or a file: {public}")
1269
+ if p == b or b in p.parents or p in b.parents:
1270
+ raise ValueError(f"the public folder and the build folder overlap: {public}")
1271
+ marker = public.parent / f".{public.name}.created-by-caddis"
1272
+ if public.exists():
1273
+ if not marker.exists() and any(public.iterdir()):
1274
+ raise ValueError(f"{public} exists and was not made by this command; remove it or choose another folder")
1275
+ shutil.rmtree(public)
1276
+ public.mkdir(parents=True)
1277
+ marker.write_text("made by caddis_roadmap.py render --site; wiped on each run\n", encoding="utf-8")
1278
+ out: list[str] = []
1279
+
1280
+ def put(src: Path, rel: str) -> None:
1281
+ dst = public / rel
1282
+ dst.parent.mkdir(parents=True, exist_ok=True)
1283
+ shutil.copyfile(src, dst)
1284
+ out.append(rel)
1285
+
1286
+ if _plain_file(build / "index.html"):
1287
+ put(build / "index.html", "index.html")
1288
+ assets = build / "_assets"
1289
+ if assets.is_dir() and not _is_link(assets):
1290
+ for dirpath, dirnames, filenames in os.walk(assets, followlinks=False):
1291
+ here = Path(dirpath)
1292
+ dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and not _is_link(here / d))
1293
+ for name in sorted(filenames):
1294
+ if name.startswith(".") or name.endswith((".json", ".body.html")) or not _plain_file(here / name):
1295
+ continue
1296
+ put(here / name, (here / name).relative_to(build).as_posix())
1297
+ for folder in sorted(build.iterdir()):
1298
+ if folder.name.startswith(".") or folder.name == "_assets" or not folder.is_dir() or _is_link(folder):
1299
+ continue
1300
+ for page_file in sorted(folder.glob("*.html")):
1301
+ slug = page_file.stem
1302
+ if (page_file.suffix == ".html" and _SLUG_RE.fullmatch(slug) and _plain_file(page_file)
1303
+ and _plain_file(folder / f"{slug}.json")):
1304
+ put(page_file, f"{folder.name}/{page_file.name}")
1305
+ return sorted(out)
@@ -899,6 +899,9 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
899
899
  cmd = build_command(ctx.engine, ctx.model, prompt, usd=usd, timeout_min=remaining_s / 60, governor=ctx.governor,
900
900
  engine_cmd=ctx.engine_cmd, worktree=cwd)
901
901
  rid = _spend_reserve(ctx.home, spend_slug, ctx.now(), n > 1) if ctx.engine == "agy" else None
902
+ if ctx.engine == "agy": # agy's tool consoles open on a hidden desktop, not on the owner's screen
903
+ g = len(ctx.governor)
904
+ cmd = [*cmd[:g], *caddis_spend.hidden_argv(cmd[g:], session_env())]
902
905
  launched, outcome = ctx.clock(), None
903
906
  try:
904
907
  _, hash_before = _read_relay(cwd, relay_slug)
@@ -42,6 +42,8 @@ CLI:
42
42
  CADDIS_SPEND_RUN_TIMEOUT_S, else 3600 s) and its process tree was killed. 75 and 78 are never
43
43
  agy's own codes for a refusal, so a caller can tell "the cap refused" from "agy failed". Each
44
44
  refusal also prints a `caddis_spend:` line.
45
+ On Windows the command runs on a hidden desktop (caddis_hidden_desktop.py), so the console windows agy opens
46
+ for its tools never show; CADDIS_AGY_VISIBLE=1 runs it on the normal desktop.
45
47
  """
46
48
 
47
49
  from __future__ import annotations
@@ -50,6 +52,7 @@ import argparse
50
52
  import json
51
53
  import math
52
54
  import os
55
+ import shutil
53
56
  import subprocess
54
57
  import sys
55
58
  import time
@@ -385,11 +388,30 @@ def _kill_tree(proc: subprocess.Popen) -> None:
385
388
  pass
386
389
 
387
390
 
391
+ HIDDEN_DESKTOP_SCRIPT = Path(__file__).with_name("caddis_hidden_desktop.py")
392
+ AGY_VISIBLE_VAR = "CADDIS_AGY_VISIBLE"
393
+
394
+
395
+ def hidden_argv(argv: list[str], env: dict | None = None) -> list[str]:
396
+ """`argv` wrapped to run on the hidden desktop (caddis_hidden_desktop.py), so the console windows agy opens for
397
+ its tools never show on the owner's screen. Unchanged when: not Windows, env CADDIS_AGY_VISIBLE=1, the launcher
398
+ is not beside this file, or the program is not found. In the last case the caller's own start raises
399
+ FileNotFoundError as before; the program is resolved here, so the launcher never has to report it."""
400
+ env = os.environ if env is None else env
401
+ if os.name != "nt" or not argv or env.get(AGY_VISIBLE_VAR) == "1" or not HIDDEN_DESKTOP_SCRIPT.is_file():
402
+ return list(argv)
403
+ program = shutil.which(argv[0], path=env.get("PATH"))
404
+ if program is None:
405
+ return list(argv)
406
+ return [sys.executable, str(HIDDEN_DESKTOP_SCRIPT), "--", program, *argv[1:]]
407
+
408
+
388
409
  def _run_child(argv: list[str], env: dict, timeout_s: float) -> tuple[int | None, str, str]:
389
- """Run `argv` with a time limit. Returns (returncode, stdout, stderr); returncode is None on a timeout, after
390
- the whole process tree was killed. Raises FileNotFoundError when the program does not exist."""
410
+ """Run `argv` with a time limit, on the hidden desktop on Windows (hidden_argv). Returns (returncode, stdout,
411
+ stderr); returncode is None on a timeout, after the whole process tree was killed. Raises FileNotFoundError when
412
+ the program does not exist."""
391
413
  group = {} if os.name == "nt" else {"start_new_session": True}
392
- proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8",
414
+ proc = subprocess.Popen(hidden_argv(argv, env), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8",
393
415
  errors="replace", env=env, **_NO_WINDOW_KW, **group)
394
416
  try:
395
417
  out, err = proc.communicate(timeout=timeout_s)