@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.
- package/bundles/antigravity-plugin/caddis_statusline.py +78 -6
- 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 +2 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +62 -5
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +406 -98
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
- 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/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
390
|
-
the whole process tree was killed. Raises FileNotFoundError when
|
|
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)
|
|
@@ -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")
|
|
@@ -43,7 +43,10 @@ def _path(root: Path, workstream: str | None) -> tuple[Path, Path]:
|
|
|
43
43
|
artifact = _artifact(root)
|
|
44
44
|
name = _name(workstream) if workstream is not None else "inbox"
|
|
45
45
|
folder = artifact / "todo-list"
|
|
46
|
-
|
|
46
|
+
# Only a folder that EXISTS can point elsewhere. Resolving one that does not exist yet raced a concurrent
|
|
47
|
+
# writer creating it: on Windows the two paths could resolve differently (a short 8.3 name on one side), and a
|
|
48
|
+
# safe add failed with this error (main CI, 20 Oct 2026, test_concurrent_inbox_add_loses_no_item).
|
|
49
|
+
if folder.is_symlink() or (folder.exists() and not folder.resolve().is_relative_to(artifact.resolve())):
|
|
47
50
|
raise ValueError("todo-list path escapes artifact root")
|
|
48
51
|
path = folder / f"{name}.md"
|
|
49
52
|
# Reads are unlocked, so refuse here as well as in locked_path. On Windows a writer replacing the file
|
|
@@ -58,7 +58,9 @@ def _plain_name(value: str) -> str:
|
|
|
58
58
|
def _artifact(root: Path) -> Path:
|
|
59
59
|
project = Path(root).resolve()
|
|
60
60
|
artifact = Path(root) / ".caddis"
|
|
61
|
-
|
|
61
|
+
# Only an existing .caddis can point elsewhere; resolving one that does not exist yet raced a concurrent writer
|
|
62
|
+
# creating it (see caddis_todo._path, 20 Oct 2026).
|
|
63
|
+
if artifact.is_symlink() or (artifact.exists() and not artifact.resolve().is_relative_to(project)):
|
|
62
64
|
raise ValueError("artifact root escapes project")
|
|
63
65
|
return artifact
|
|
64
66
|
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED