@caddis/cli 0.4.60 → 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.
@@ -71,8 +71,9 @@ DEFAULT_CONFIG = {
71
71
  "bar_width": 8,
72
72
  "token_counts": True, # ◉ 412k/1M — off falls back to a bare ctx:41%
73
73
  "cached_tokens": True, # ⚡38k cached — rendered LOUD, see render_claude()
74
- "ram": True, # 💻 69% (44 GB)
75
- "clock": True, # ◷ 03:41
74
+ "ram": False, # 💻 69% (44 GB) - off by default (owner, 20 Oct 2026)
75
+ "thinking": False, # Claude's "thinking" flag, agy's "thinking" state - off by default (20 Oct 2026)
76
+ "clock": False, # ◷ 03:41 - off by default (owner, 20 Oct 2026)
76
77
  "relay": True, # ⏺ relay (a parked resume pointer exists)
77
78
  "rate_limits": True, # 5h:12% 7d:40%
78
79
  "session_id": False, # #a1b2c3d4
@@ -468,7 +469,7 @@ def cached_segment(c: C, cfg: dict, cached: int) -> str:
468
469
 
469
470
 
470
471
  def ram_segment(c: C, cfg: dict) -> str:
471
- if not cfg.get("ram", True):
472
+ if not cfg.get("ram", False):
472
473
  return ""
473
474
  min_w = as_int(cfg.get("ram_min_width"), 0)
474
475
  if min_w and term_width() < min_w:
@@ -482,7 +483,7 @@ def ram_segment(c: C, cfg: dict) -> str:
482
483
 
483
484
 
484
485
  def clock_segment(c: C, cfg: dict) -> str:
485
- if not cfg.get("clock", True):
486
+ if not cfg.get("clock", False):
486
487
  return ""
487
488
  return c.dim + icon(cfg, "clock") + time.strftime("%H:%M") + c.reset
488
489
 
@@ -549,7 +550,7 @@ def render_claude(data: dict, cfg: dict) -> str:
549
550
  if style and str(style).lower() != "default":
550
551
  parts.append(c.blue + "style:" + str(style) + c.reset)
551
552
 
552
- if dig(data, "thinking", "enabled") is True:
553
+ if cfg.get("thinking", False) and dig(data, "thinking", "enabled") is True:
553
554
  parts.append(c.magenta + "thinking" + c.reset)
554
555
 
555
556
  if cfg.get("rate_limits", True):
@@ -581,7 +582,7 @@ def render_claude(data: dict, cfg: dict) -> str:
581
582
 
582
583
  session_name = dig(data, "session_name")
583
584
  if session_name:
584
- parts.append(c.dim + '"' + str(session_name) + '"' + c.reset)
585
+ parts.append(c.bold + c.magenta + str(session_name) + c.reset) # bright, so the session is easy to spot
585
586
 
586
587
  if cfg.get("session_id", False):
587
588
  sid = str(dig(data, "session_id") or "")
@@ -616,6 +617,8 @@ def render_agy(data: dict, cfg: dict) -> str:
616
617
  parts.append(c.white + c.bold + "agy" + c.reset)
617
618
 
618
619
  state = str(dig(data, "agent_state") or dig(data, "execution_mode") or "").lower()
620
+ if state == "thinking" and not cfg.get("thinking", False):
621
+ state = "" # working and idle still show
619
622
  if state:
620
623
  label, col = {
621
624
  "working": (icon(cfg, "bolt") + "working", c.green),
@@ -704,6 +707,61 @@ HOST_SETTINGS = {
704
707
  }
705
708
 
706
709
 
710
+ VERSION_STAMP = INSTALL_DIR / "statusline.version"
711
+
712
+
713
+ def plugin_version(here: Path | None = None) -> str:
714
+ """The caddis version of the plugin this file ships in: `.claude-plugin/plugin.json` or a root `plugin.json`
715
+ one folder above `scripts/`. "" in a source checkout or when unreadable."""
716
+ base = Path(here or Path(__file__).resolve().parent).parent
717
+ for manifest in (base / ".claude-plugin" / "plugin.json", base / "plugin.json"):
718
+ try:
719
+ data = json.loads(manifest.read_text(encoding="utf-8"))
720
+ except Exception:
721
+ continue
722
+ if isinstance(data, dict) and str(data.get("name", "")).startswith("caddis") and data.get("version"):
723
+ return str(data["version"])
724
+ return ""
725
+
726
+
727
+ def _version_key(text: str):
728
+ parts = str(text or "").strip().split(".")
729
+ return tuple(int(x) for x in parts) if parts and all(x.isdigit() for x in parts) else None
730
+
731
+
732
+ def refresh_installed(src: Path | None = None, version: str | None = None) -> bool:
733
+ """Bring the installed renderer (`~/.caddis/statusline.py`) up to this plugin's copy.
734
+
735
+ Why (20 Oct 2026): only `--install` copied the renderer, so a caddis update never reached it. This machine
736
+ kept a copy whose `git` calls opened a console window each time agy drew its status line.
737
+ Rules: nothing happens unless the status line was installed; the copy happens only when this plugin's version
738
+ is newer than the one stamped at the last copy (an older session never downgrades it); a source checkout
739
+ (no plugin version) never copies. Returns True when it copied."""
740
+ src = Path(src or Path(__file__).resolve())
741
+ version = plugin_version() if version is None else version
742
+ if not INSTALLED_SCRIPT.is_file() or _version_key(version) is None:
743
+ return False
744
+ try:
745
+ stamped = VERSION_STAMP.read_text(encoding="utf-8").strip()
746
+ except OSError:
747
+ stamped = ""
748
+ old = _version_key(stamped)
749
+ if old is not None and old >= _version_key(version):
750
+ return False
751
+ try:
752
+ if src.resolve() != INSTALLED_SCRIPT.resolve() and src.read_bytes() != INSTALLED_SCRIPT.read_bytes():
753
+ tmp = INSTALLED_SCRIPT.with_name("%s.%d.tmp" % (INSTALLED_SCRIPT.name, os.getpid()))
754
+ shutil.copyfile(str(src), str(tmp))
755
+ os.replace(str(tmp), str(INSTALLED_SCRIPT))
756
+ copied = True
757
+ else:
758
+ copied = False
759
+ VERSION_STAMP.write_text(version + "\n", encoding="utf-8")
760
+ return copied
761
+ except OSError:
762
+ return False # a session start must never fail over the status line
763
+
764
+
707
765
  def python_command() -> str:
708
766
  """The interpreter NAME to write into settings — not an absolute path.
709
767
 
@@ -726,6 +784,14 @@ def install(hosts, dry_run: bool = False) -> int:
726
784
  # because the version is in the path.
727
785
  if not dry_run and src != INSTALLED_SCRIPT.resolve():
728
786
  shutil.copyfile(str(src), str(INSTALLED_SCRIPT))
787
+ if not dry_run:
788
+ # Stamp the version, so a later --refresh from an OLDER plugin session cannot replace this copy.
789
+ version = plugin_version()
790
+ if _version_key(version) is not None:
791
+ try:
792
+ VERSION_STAMP.write_text(version + "\n", encoding="utf-8")
793
+ except OSError:
794
+ pass
729
795
  print("renderer -> %s" % INSTALLED_SCRIPT)
730
796
 
731
797
  if not CONFIG_PATH.exists():
@@ -903,8 +969,14 @@ def main(argv=None) -> int:
903
969
  parser.add_argument("--dry-run", action="store_true", help="with --install: print, write nothing")
904
970
  parser.add_argument("--no-color", action="store_true", help="render without ANSI colour")
905
971
  parser.add_argument("--title", action="store_true", help="render terminal title (agy)")
972
+ parser.add_argument("--refresh", action="store_true",
973
+ help="copy this plugin's renderer over an older installed one (session-start hook)")
906
974
  args = parser.parse_args(argv)
907
975
 
976
+ if args.refresh:
977
+ refresh_installed()
978
+ return 0 # silent and never failing: it runs at every session start
979
+
908
980
  # The line is Unicode. On Windows the default cp1252 stdout raises on ◆/⎇/█.
909
981
  try:
910
982
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.146",
3
+ "version": "1.3.148",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -31,6 +31,7 @@ Ported into caddis 2026-09-12 from a personal hook that existed on one machine o
31
31
  from __future__ import annotations
32
32
 
33
33
  import json
34
+ import math
34
35
  import os
35
36
  import re
36
37
  import signal
@@ -366,10 +367,21 @@ def _fetch(cwd: str) -> "bool | None":
366
367
  return False
367
368
 
368
369
 
370
+ def _budget_s() -> float:
371
+ """TOTAL_BUDGET_S, or a larger budget from CADDIS_FRESHNESS_BUDGET_S. Only the tests set it: on a busy CI runner
372
+ the git calls before the fetch can use up 18 s, and the hook then rightly skips the fetch, which made tests about
373
+ the fetch fail at random (20 Oct 2026). An unset, empty or bad value means TOTAL_BUDGET_S."""
374
+ try:
375
+ value = float(os.environ.get("CADDIS_FRESHNESS_BUDGET_S", ""))
376
+ except ValueError:
377
+ return TOTAL_BUDGET_S
378
+ return value if math.isfinite(value) and value > 0 else TOTAL_BUDGET_S
379
+
380
+
369
381
  def report(cwd: str, own_session_id: str = "") -> list[str]:
370
382
  """The lines to print. Empty when there is nothing worth saying."""
371
383
  global _deadline
372
- _deadline = time.monotonic() + TOTAL_BUDGET_S
384
+ _deadline = time.monotonic() + _budget_s()
373
385
  try:
374
386
  return _report(cwd, own_session_id)
375
387
  finally:
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """caddis_hidden_desktop - run a command on a separate, invisible Windows desktop.
3
+
4
+ Why (20 Oct 2026, seen on the owner's screen): agy starts its own tool processes (shell commands, MCP servers) as
5
+ visible consoles, so every agy call flashed windows on the desktop. CREATE_NO_WINDOW and SW_HIDE on agy do not
6
+ reach those grandchildren. A process started on another desktop opens all its windows there, and its children
7
+ inherit that desktop, so nothing appears on the owner's screen.
8
+
9
+ python caddis_hidden_desktop.py -- <program> [args...]
10
+
11
+ - Creates (or opens, when it exists) the desktop "caddis-hidden" in this process's window station and starts the
12
+ command on it with CREATE_NO_WINDOW. The command inherits this process's stdin, stdout and stderr handles, its
13
+ environment and its working directory.
14
+ - The command runs in a job object with KILL_ON_JOB_CLOSE: when this launcher ends for any reason (normal exit,
15
+ Ctrl-C, taskkill), the command and everything it started end too.
16
+ - Exits with the command's exit code.
17
+ - When the hidden desktop cannot be used, runs the command normally (CREATE_NO_WINDOW) and says so on stderr once.
18
+ Hiding never makes a call fail. A program that does not exist exits 127.
19
+ - On other operating systems it just runs the command.
20
+
21
+ caddis_spend.hidden_argv() builds the command line that uses this file; set CADDIS_AGY_VISIBLE=1 to opt out.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import subprocess
28
+ import sys
29
+
30
+ DESKTOP_NAME = "caddis-hidden"
31
+ CREATE_NO_WINDOW = 0x08000000
32
+ EXIT_NOT_FOUND = 127
33
+ EXIT_INTERRUPTED = 130
34
+
35
+
36
+ def _signed(code: int) -> int:
37
+ """A DWORD exit code as the signed int sys.exit accepts on Windows (same bits for ExitProcess)."""
38
+ return code - (1 << 32) if code >= (1 << 31) else code
39
+
40
+
41
+ def _fallback(argv: list[str], reason: str) -> int:
42
+ print(f"caddis_hidden_desktop: {reason}; running the command on the normal desktop", file=sys.stderr, flush=True)
43
+ return _run_plain(argv)
44
+
45
+
46
+ def _run_plain(argv: list[str]) -> int:
47
+ flags = {"creationflags": CREATE_NO_WINDOW} if os.name == "nt" else {}
48
+ try:
49
+ proc = subprocess.Popen(argv, **flags)
50
+ except FileNotFoundError:
51
+ print(f"caddis_hidden_desktop: command not found: {argv[0]}", file=sys.stderr, flush=True)
52
+ return EXIT_NOT_FOUND
53
+ try:
54
+ return proc.wait()
55
+ except BaseException:
56
+ proc.kill()
57
+ raise
58
+
59
+
60
+ def _run_hidden(argv: list[str], started: list[bool]) -> int: # pragma: no cover - Windows only
61
+ """Sets started[0] once the command runs: from then on an error must never start it a second time."""
62
+ import ctypes
63
+ import ctypes.wintypes as wt
64
+ import _winapi
65
+
66
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
67
+ u32 = ctypes.WinDLL("user32", use_last_error=True)
68
+
69
+ class STARTUPINFOW(ctypes.Structure):
70
+ _fields_ = [("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), ("lpTitle", wt.LPWSTR),
71
+ ("dwX", wt.DWORD), ("dwY", wt.DWORD), ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD),
72
+ ("dwXCountChars", wt.DWORD), ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD),
73
+ ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD),
74
+ ("lpReserved2", ctypes.c_void_p), ("hStdInput", wt.HANDLE), ("hStdOutput", wt.HANDLE),
75
+ ("hStdError", wt.HANDLE)]
76
+
77
+ class PROCESS_INFORMATION(ctypes.Structure):
78
+ _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), ("dwProcessId", wt.DWORD),
79
+ ("dwThreadId", wt.DWORD)]
80
+
81
+ class IO_COUNTERS(ctypes.Structure):
82
+ _fields_ = [(n, ctypes.c_ulonglong) for n in ("ReadOps", "WriteOps", "OtherOps", "ReadBytes", "WriteBytes",
83
+ "OtherBytes")]
84
+
85
+ class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
86
+ _fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong), ("PerJobUserTimeLimit", ctypes.c_longlong),
87
+ ("LimitFlags", wt.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
88
+ ("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wt.DWORD),
89
+ ("Affinity", ctypes.c_size_t), ("PriorityClass", wt.DWORD), ("SchedulingClass", wt.DWORD)]
90
+
91
+ class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
92
+ _fields_ = [("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), ("IoInfo", IO_COUNTERS),
93
+ ("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t),
94
+ ("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t)]
95
+
96
+ GENERIC_ALL = 0x10000000
97
+ CREATE_SUSPENDED = 0x00000004
98
+ STARTF_USESTDHANDLES = 0x00000100
99
+ UOI_NAME = 2
100
+ WAIT_OBJECT_0 = 0
101
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
102
+ JobObjectExtendedLimitInformation = 9
103
+
104
+ u32.GetProcessWindowStation.restype = wt.HANDLE
105
+ u32.GetUserObjectInformationW.argtypes = [wt.HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD,
106
+ ctypes.POINTER(wt.DWORD)]
107
+ u32.CreateDesktopW.restype = wt.HANDLE
108
+ u32.CreateDesktopW.argtypes = [wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD, wt.DWORD, ctypes.c_void_p]
109
+ u32.CloseDesktop.argtypes = [wt.HANDLE]
110
+ k32.CreateProcessW.argtypes = [wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL, wt.DWORD,
111
+ ctypes.c_void_p, wt.LPCWSTR, ctypes.POINTER(STARTUPINFOW),
112
+ ctypes.POINTER(PROCESS_INFORMATION)]
113
+ k32.CreateJobObjectW.restype = wt.HANDLE
114
+ k32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wt.LPCWSTR]
115
+ k32.SetInformationJobObject.argtypes = [wt.HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD]
116
+ k32.AssignProcessToJobObject.argtypes = [wt.HANDLE, wt.HANDLE]
117
+ k32.TerminateJobObject.argtypes = [wt.HANDLE, wt.UINT]
118
+ k32.ResumeThread.argtypes = [wt.HANDLE]
119
+ k32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
120
+ k32.WaitForSingleObject.restype = wt.DWORD
121
+ k32.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)]
122
+ k32.CloseHandle.argtypes = [wt.HANDLE]
123
+
124
+ # The desktop must live in this process's window station (WinSta0 for a logged-on user, another one for a
125
+ # service); a desktop path in a station the process cannot reach makes CreateProcessW fail.
126
+ station = "WinSta0"
127
+ buf = ctypes.create_unicode_buffer(256)
128
+ needed = wt.DWORD()
129
+ hsta = u32.GetProcessWindowStation()
130
+ if hsta and u32.GetUserObjectInformationW(hsta, UOI_NAME, buf, ctypes.sizeof(buf), ctypes.byref(needed)):
131
+ station = buf.value or station
132
+
133
+ hdesk = u32.CreateDesktopW(DESKTOP_NAME, None, None, 0, GENERIC_ALL, None) # opens it when it exists
134
+ if not hdesk:
135
+ return _fallback(argv, f"cannot create the hidden desktop (error {ctypes.get_last_error()})")
136
+
137
+ me = _winapi.GetCurrentProcess()
138
+ inherited: list[int] = []
139
+
140
+ def inheritable(which: int) -> int:
141
+ handle = _winapi.GetStdHandle(which)
142
+ if not handle or handle == _winapi.INVALID_HANDLE_VALUE:
143
+ return 0
144
+ try:
145
+ dup = _winapi.DuplicateHandle(me, handle, me, 0, True, _winapi.DUPLICATE_SAME_ACCESS)
146
+ except OSError:
147
+ return 0
148
+ inherited.append(dup)
149
+ return dup
150
+
151
+ job = None
152
+ pi = PROCESS_INFORMATION()
153
+ try:
154
+ si = STARTUPINFOW()
155
+ si.cb = ctypes.sizeof(si)
156
+ si.lpDesktop = f"{station}\\{DESKTOP_NAME}"
157
+ si.dwFlags = STARTF_USESTDHANDLES
158
+ si.hStdInput = inheritable(_winapi.STD_INPUT_HANDLE)
159
+ si.hStdOutput = inheritable(_winapi.STD_OUTPUT_HANDLE)
160
+ si.hStdError = inheritable(_winapi.STD_ERROR_HANDLE)
161
+ cmdline = ctypes.create_unicode_buffer(subprocess.list2cmdline(argv))
162
+ if not k32.CreateProcessW(None, cmdline, None, None, True, CREATE_NO_WINDOW | CREATE_SUSPENDED, None, None,
163
+ ctypes.byref(si), ctypes.byref(pi)):
164
+ error = ctypes.get_last_error()
165
+ if error in (2, 3): # ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND
166
+ print(f"caddis_hidden_desktop: command not found: {argv[0]}", file=sys.stderr, flush=True)
167
+ return EXIT_NOT_FOUND
168
+ return _fallback(argv, f"cannot start the command on the hidden desktop (error {error})")
169
+ started[0] = True
170
+ for dup in inherited: # the child has its own copies now
171
+ _winapi.CloseHandle(dup)
172
+ inherited.clear()
173
+
174
+ job = k32.CreateJobObjectW(None, None)
175
+ if job:
176
+ info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
177
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
178
+ if not (k32.SetInformationJobObject(job, JobObjectExtendedLimitInformation, ctypes.byref(info),
179
+ ctypes.sizeof(info))
180
+ and k32.AssignProcessToJobObject(job, pi.hProcess)):
181
+ k32.CloseHandle(job) # the caller's taskkill /T still reaches the child through its parent id
182
+ job = None
183
+ k32.ResumeThread(pi.hThread)
184
+ try:
185
+ # Short waits so Ctrl-C gets through. Loop only on a timeout: WAIT_FAILED (a bad handle) must not spin.
186
+ while k32.WaitForSingleObject(pi.hProcess, 200) == 0x00000102: # WAIT_TIMEOUT
187
+ pass
188
+ except KeyboardInterrupt:
189
+ if job:
190
+ k32.TerminateJobObject(job, EXIT_INTERRUPTED)
191
+ else:
192
+ _winapi.TerminateProcess(int(pi.hProcess), EXIT_INTERRUPTED)
193
+ return EXIT_INTERRUPTED
194
+ code = wt.DWORD()
195
+ k32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code))
196
+ return _signed(code.value)
197
+ finally:
198
+ for dup in inherited:
199
+ _winapi.CloseHandle(dup)
200
+ if pi.hThread:
201
+ k32.CloseHandle(pi.hThread)
202
+ if pi.hProcess:
203
+ k32.CloseHandle(pi.hProcess)
204
+ if job:
205
+ k32.CloseHandle(job) # KILL_ON_JOB_CLOSE: anything the command left running ends here
206
+ u32.CloseDesktop(hdesk)
207
+
208
+
209
+ def run(argv: list[str]) -> int:
210
+ if not argv:
211
+ print("usage: caddis_hidden_desktop.py -- <program> [args...]", file=sys.stderr)
212
+ return 2
213
+ if os.name != "nt":
214
+ return _run_plain(argv)
215
+ started = [False]
216
+ try:
217
+ return _run_hidden(argv, started)
218
+ except Exception as exc:
219
+ if started[0]: # it ran (and may have spent): never run it twice; the job already ended it
220
+ raise
221
+ return _fallback(argv, f"hidden desktop failed ({exc!r})") # never fail a call because hiding failed
222
+
223
+
224
+ def main(argv: list[str] | None = None) -> int:
225
+ args = list(sys.argv[1:] if argv is None else argv)
226
+ if args[:1] == ["--"]:
227
+ args = args[1:]
228
+ return run(args)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())
@@ -612,6 +612,7 @@ def run_lane(
612
612
  if spend_id is None:
613
613
  return _refused_run(1, "caddis_spend: over cap: agy refused ("
614
614
  + caddis_spend.status_line(spend_home, datetime.now(timezone.utc)) + ")")
615
+ argv = caddis_spend.hidden_argv(argv, env) # agy's tool consoles open on a hidden desktop, not on screen
615
616
  job_name = f"Local\\caddis-governor-{os.getpid()}-{secrets.token_hex(8)}" if sys.platform == "win32" else None
616
617
  full = governed_argv(argv, job_name)
617
618
  if not stdin_prompt:
@@ -1081,7 +1082,7 @@ def _probe_agy_models() -> str:
1081
1082
  return ""
1082
1083
  try:
1083
1084
  proc = subprocess.run(
1084
- [agy, "models"], capture_output=True, text=True, encoding="utf-8", errors="replace",
1085
+ caddis_spend.hidden_argv([agy, "models"]), capture_output=True, text=True, encoding="utf-8", errors="replace",
1085
1086
  timeout=AGY_PROBE_TIMEOUT_S, check=False, stdin=subprocess.DEVNULL,
1086
1087
  )
1087
1088
  except (OSError, subprocess.SubprocessError):
@@ -2231,6 +2231,9 @@ def _build_parser() -> argparse.ArgumentParser:
2231
2231
  which.add_argument("--all", action="store_true", help="every roadmap on origin (used by hooks and commands)")
2232
2232
  p.add_argument("--root", default=".")
2233
2233
  p.add_argument("--out", default=None, help="the HTML file (default ~/.caddis/roadmap-pages/<repo>/<slug>.html)")
2234
+ p.add_argument("--site", default=None, metavar="DIR",
2235
+ help="with --all: build the shared copy (no live sessions or runners, a banner) in DIR/build and "
2236
+ "publish only the safe files to DIR/public (for GitHub Pages)")
2234
2237
  p.add_argument("--no-fetch", action="store_true",
2235
2238
  help="use the origin refs this checkout has; no git fetch (the waiting hook's background render)")
2236
2239
  p.add_argument("--delay", type=int, default=0,
@@ -2287,15 +2290,17 @@ def _take_render_lock(lock: Path) -> bool:
2287
2290
  return False
2288
2291
 
2289
2292
 
2290
- def _render(root: Path, args: argparse.Namespace) -> int:
2291
- """`render`: the pages, the prune and the hub. The caller holds the repo's render lock."""
2293
+ def _render(root: Path, args: argparse.Namespace, site_dir: Path | None = None) -> int:
2294
+ """`render`: the pages, the prune and the hub. The caller holds the repo's render lock. `site_dir` (the shared
2295
+ copy's build folder, `render --site`) replaces the pages root and takes no lock."""
2292
2296
  import caddis_roadmap_page
2293
2297
  slugs, on_origin = list_roadmaps_checked(root) if args.all else ([args.roadmap], None)
2294
2298
  failed = written = pruned = 0
2299
+ site_kw = {} if site_dir is None else {"site_dir": site_dir} # the normal calls stay exactly as they were
2295
2300
  for slug in slugs: # --all: one bad roadmap must not stop the others
2296
2301
  try:
2297
2302
  path = caddis_roadmap_page.write_page(root, slug, Path(args.out) if args.out else None,
2298
- _THIS_MODULE)
2303
+ _THIS_MODULE, **site_kw)
2299
2304
  if path is None: # a newer caddis owns this page: kept, and neither written nor failed
2300
2305
  continue
2301
2306
  written += 1
@@ -2309,15 +2314,57 @@ def _render(root: Path, args: argparse.Namespace) -> int:
2309
2314
  if args.all and on_origin is not None: # a roadmap removed from origin leaves the hub too; a listing
2310
2315
  # that failed removes nothing, and a folder still on origin keeps its page even if its render failed
2311
2316
  pruned = caddis_roadmap_page.prune_folder(
2312
- caddis_roadmap_page.default_page_path(root, "_").parent, on_origin | set(slugs))
2313
- hub = caddis_roadmap_page.write_hub() if not args.out and (written or pruned) else None
2317
+ caddis_roadmap_page.default_page_path(root, "_", base=site_dir).parent, on_origin | set(slugs))
2318
+ if site_dir is not None:
2319
+ site_kw["banner"] = caddis_roadmap_page.site_banner(root)
2320
+ hub = caddis_roadmap_page.write_hub(**site_kw) if not args.out and (written or pruned) else None
2314
2321
  if hub is not None: # None: a newer caddis owns the hub (write_hub said so)
2315
2322
  print(f"[ROADMAP] hub written: {hub.resolve()}")
2316
2323
  except Exception as exc:
2317
2324
  print(f"[ROADMAP] hub not written: {exc}", file=sys.stderr)
2325
+ if site_dir is not None: # the shared copy is published, so a hub that failed is a failed render
2326
+ failed += 1
2327
+ if site_dir is not None and not failed and not written:
2328
+ print("[ROADMAP] site not rendered: no roadmap was rendered (none on origin, or the listing failed)",
2329
+ file=sys.stderr)
2330
+ return caddis_exit.BLOCKED
2318
2331
  return caddis_exit.BLOCKED if failed else caddis_exit.CLEAN
2319
2332
 
2320
2333
 
2334
+ def _render_site(root: Path, args: argparse.Namespace) -> int:
2335
+ """`render --all --site DIR`: build the shared copy in DIR/build (no render lock, no version gate, no stamp: CI is
2336
+ the only writer), then publish its allow-list to DIR/public. A render that failed publishes nothing."""
2337
+ import caddis_roadmap_page
2338
+ site = Path(args.site)
2339
+ build, public = site / "build", site / "public"
2340
+ global FETCH
2341
+ FETCH = not args.no_fetch
2342
+ try: # a reused DIR keeps the last run's index.html: only one written by THIS run may be published
2343
+ (build / "index.html").unlink(missing_ok=True)
2344
+ except OSError as exc:
2345
+ raise RoadmapError(f"site not published: the old {build / 'index.html'} cannot be removed: {exc}",
2346
+ caddis_exit.ERROR) from exc
2347
+ try:
2348
+ code = _render(root, args, site_dir=build)
2349
+ finally:
2350
+ FETCH = True
2351
+ if code != caddis_exit.CLEAN:
2352
+ print("[ROADMAP] site not published: the render did not finish clean (see above); the last public/ is kept",
2353
+ file=sys.stderr)
2354
+ return code
2355
+ if not (build / "index.html").is_file():
2356
+ print("[ROADMAP] site not published: the hub (index.html) was not written", file=sys.stderr)
2357
+ return caddis_exit.BLOCKED
2358
+ try:
2359
+ paths = caddis_roadmap_page.export_site(build, public)
2360
+ except ValueError as exc:
2361
+ raise RoadmapError(f"site not published: {exc}", caddis_exit.ERROR) from exc
2362
+ for rel in paths:
2363
+ print(f"[ROADMAP] site file: {rel}")
2364
+ print(f"[ROADMAP] site published: {len(paths)} file(s) in {public.resolve()}")
2365
+ return caddis_exit.CLEAN
2366
+
2367
+
2321
2368
  def _dispatch(args: argparse.Namespace) -> int:
2322
2369
  root = _repo_root(args.root)
2323
2370
  if args.cmd == "list":
@@ -2361,6 +2408,16 @@ def _dispatch(args: argparse.Namespace) -> int:
2361
2408
  return recheck(root, args.roadmap, args.dry_run)
2362
2409
  if args.cmd == "render":
2363
2410
  import caddis_roadmap_page # the page is separate; only `render` needs it
2411
+ if args.site is not None:
2412
+ if args.out:
2413
+ raise RoadmapError("--site and --out cannot be used together: --out is one self-contained page, "
2414
+ "--site is the shared copy of every roadmap", caddis_exit.ERROR)
2415
+ if not args.all:
2416
+ raise RoadmapError("--site works with --all, not with --roadmap", caddis_exit.ERROR)
2417
+ if args.delay > 0:
2418
+ raise RoadmapError("--site and --delay cannot be used together: a CI render does not wait",
2419
+ caddis_exit.ERROR)
2420
+ return _render_site(root, args)
2364
2421
  if args.all and args.out:
2365
2422
  raise RoadmapError("--out works with one --roadmap, not with --all", caddis_exit.ERROR)
2366
2423
  if args.delay > 0: