@caddis/cli 0.4.61 → 0.4.63
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.
- package/bundles/antigravity-plugin/caddis_statusline.py +78 -6
- package/bundles/antigravity-plugin/guard_agy.py +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +13 -1
- package/bundles/antigravity-plugin/scripts/caddis_hidden_desktop.py +232 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +62 -5
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +306 -54
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +10 -4
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +25 -3
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +78 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +4 -1
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +3 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +109 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +5 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +10 -3
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +10 -3
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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":
|
|
75
|
-
"
|
|
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",
|
|
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",
|
|
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.
|
|
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")
|
|
@@ -953,7 +953,8 @@ def shared_checkout_reason(command: str, root: str | None) -> str:
|
|
|
953
953
|
if _moves_tree(sub, args, cwd) and _declares_shared(cwd):
|
|
954
954
|
return (f"`git {sub.lower()}` moves the tree of a shared checkout (AGENTS.md says Shared checkout: yes; "
|
|
955
955
|
"a dev service or a peer uses it). Work in your own worktree instead: "
|
|
956
|
-
"`git worktree add
|
|
956
|
+
"`git worktree add <worktree root>/<repo>/<name> -b <branch> origin/<default>` (never under %TEMP%: "
|
|
957
|
+
"`caddis_worktree_root.py <name>` prints the path), and remove it when done")
|
|
957
958
|
return ""
|
|
958
959
|
|
|
959
960
|
|
|
@@ -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() +
|
|
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):
|
|
@@ -2249,7 +2250,7 @@ def _accept_command(
|
|
|
2249
2250
|
unsafe = shared_checkout_reason(repo)
|
|
2250
2251
|
if unsafe and not getattr(args, "allow_shared", False):
|
|
2251
2252
|
print(f"refusing: the session checkout is not safe to fast-forward ({_printable(unsafe)}). "
|
|
2252
|
-
"Run the programme from a worktree of origin/<default>
|
|
2253
|
+
"Run the programme from a worktree of origin/<default> (git worktree add; `caddis_worktree_root.py` prints the path, never %TEMP%), "
|
|
2253
2254
|
"or pass --allow-shared to fast-forward here anyway")
|
|
2254
2255
|
return EXIT_USAGE
|
|
2255
2256
|
if unsafe:
|
|
@@ -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
|
-
|
|
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:
|