@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.
@@ -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)
@@ -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")
@@ -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
- if folder.is_symlink() or not folder.resolve().is_relative_to(artifact.resolve()):
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
- if not artifact.resolve().is_relative_to(project) or artifact.is_symlink():
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
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.146",
2
+ "poolVersion": "1.3.148",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.146",
4
+ "antigravity-plugin": "1.3.148",
5
5
  "antigravity-plugin-extras": "1.3.45",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.60",
3
+ "version": "0.4.62",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",