@caddis/cli 0.4.57 → 0.4.59

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.
@@ -329,7 +329,12 @@ def ram_facts():
329
329
  return None
330
330
 
331
331
 
332
- def record_context(cwd: str, pct: float) -> None:
332
+ def _safe_session_id(session_id) -> str:
333
+ """A file-name-safe form of a session id (letters, digits, - and _ only, 64 chars at most); "" when empty."""
334
+ return "".join(ch if (ch.isalnum() and ch.isascii()) or ch in "-_" else "_" for ch in str(session_id or ""))[:64].strip(".")
335
+
336
+
337
+ def record_context(cwd: str, pct: float, session_id: str = "") -> None:
333
338
  """Cache the runtime context figure for tools that never see the statusline payload.
334
339
 
335
340
  Claude Code hands `context_window.used_percentage` to the statusline and to NOTHING else.
@@ -342,6 +347,11 @@ def record_context(cwd: str, pct: float) -> None:
342
347
  render. Only writes where `.caddis/` already exists, so cd-ing into an unrelated folder
343
348
  never scatters artifact dirs. Fail-open and silent: a status line must never break a
344
349
  prompt, and this is the least important thing it does.
350
+
351
+ One folder can host several sessions, so the figure is also kept per session, in
352
+ `.caddis/context-window/<session id>.json`; a reader that knows its session id reads that file and
353
+ never another session's figure. `context-window.json` stays as the shared "last writer" for a reader
354
+ that does not know its session (robustness #19). Per-session files older than 2 days are removed.
345
355
  """
346
356
  try:
347
357
  if not cwd:
@@ -349,20 +359,44 @@ def record_context(cwd: str, pct: float) -> None:
349
359
  art = os.path.join(cwd, ".caddis")
350
360
  if not os.path.isdir(art):
351
361
  return
352
- target = os.path.join(art, "context-window.json")
353
362
  payload = {"used_percentage": round(float(pct), 1), "at": int(time.time())}
354
- try:
355
- with open(target, encoding="utf-8") as fh:
356
- if json.load(fh).get("used_percentage") == payload["used_percentage"]:
357
- return
358
- except Exception:
359
- pass
360
- tmp = target + ".tmp"
363
+ sid = _safe_session_id(session_id)
364
+ if sid:
365
+ per_dir = os.path.join(art, "context-window")
366
+ os.makedirs(per_dir, exist_ok=True)
367
+ _write_if_changed(os.path.join(per_dir, sid + ".json"), payload)
368
+ try:
369
+ cutoff = time.time() - 2 * 86400
370
+ for name in os.listdir(per_dir):
371
+ old = os.path.join(per_dir, name)
372
+ if os.path.getmtime(old) < cutoff:
373
+ os.unlink(old)
374
+ except Exception:
375
+ pass
376
+ _write_if_changed(os.path.join(art, "context-window.json"), payload)
377
+ except Exception:
378
+ pass
379
+
380
+
381
+ def _write_if_changed(target: str, payload: dict) -> None:
382
+ """Write the JSON `payload` atomically (a temp file named for this process), only when the figure changed."""
383
+ try:
384
+ with open(target, encoding="utf-8") as fh:
385
+ if json.load(fh).get("used_percentage") == payload["used_percentage"]:
386
+ return
387
+ except Exception:
388
+ pass
389
+ tmp = f"{target}.{os.getpid()}.tmp"
390
+ try:
361
391
  with open(tmp, "w", encoding="utf-8") as fh:
362
392
  json.dump(payload, fh)
363
393
  os.replace(tmp, target)
364
394
  except Exception:
365
- pass
395
+ try:
396
+ os.unlink(tmp)
397
+ except OSError:
398
+ pass
399
+ raise
366
400
 
367
401
 
368
402
  def relay_present(cwd: str) -> bool:
@@ -501,7 +535,7 @@ def render_claude(data: dict, cfg: dict) -> str:
501
535
  used = as_int(cw.get("total_input_tokens"))
502
536
  max_tokens = as_int(cw.get("context_window_size"))
503
537
  pct = float(as_int(cw.get("used_percentage")))
504
- record_context(cwd, pct) # feeds /caddis:spawn-session's 95% gate; see record_context
538
+ record_context(cwd, pct, str(dig(data, "session_id") or "")) # feeds /caddis:spawn-session's 95% gate; see record_context
505
539
  parts.append(context_segment(c, cfg, used, max_tokens, pct))
506
540
  loud = cached_segment(c, cfg, cached)
507
541
  if loud:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.143",
3
+ "version": "1.3.145",
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
  }
@@ -12,6 +12,7 @@ import ast
12
12
  import json
13
13
  import os
14
14
  import re
15
+ import secrets
15
16
  import shutil
16
17
  import subprocess
17
18
  import sys
@@ -131,7 +132,8 @@ def slugify_prompt(prompt: str) -> str:
131
132
  if not slug:
132
133
  slug = "task"
133
134
  date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
134
- return f"{date_str}-{slug}"
135
+ # Random suffix: two runs on the same day and topic must never share a branch, plan or run dir.
136
+ return f"{date_str}-{slug}-{secrets.token_hex(3)}"
135
137
 
136
138
 
137
139
  def synthesize_plan(
@@ -168,18 +170,73 @@ Task requirements are met, existing tests pass, and new test assertions verify t
168
170
  return plan_path, slug, title
169
171
 
170
172
 
173
+ def session_key() -> str:
174
+ """Id of this agent session, safe in a file name. One checkout can host several sessions."""
175
+ for var in ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"):
176
+ raw = os.environ.get(var, "").strip()
177
+ key = re.sub(r"[^A-Za-z0-9_-]", "-", raw)[:64].strip("-")
178
+ if key:
179
+ return key
180
+ return "nosession"
181
+
182
+
183
+ def _adhoc_dir(repo: Path) -> Path:
184
+ return repo / ".caddis" / "orchestrator" / "adhoc"
185
+
186
+
171
187
  def adhoc_latest_path(repo: Path) -> Path:
172
- return repo / ".caddis" / "orchestrator" / "adhoc" / "latest.json"
188
+ return _adhoc_dir(repo) / f"latest-{session_key()}.json"
189
+
190
+
191
+ def write_latest(repo: Path, state: dict) -> None:
192
+ path = adhoc_latest_path(repo)
193
+ path.parent.mkdir(parents=True, exist_ok=True)
194
+ path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
173
195
 
174
196
 
175
197
  def read_latest(repo: Path) -> dict | None:
176
198
  path = adhoc_latest_path(repo)
177
199
  if not path.is_file():
178
- return None
200
+ # Migration: the old single latest.json belongs to whoever reads it first. The rename is
201
+ # atomic, so exactly one session adopts it and no other session ever sees it.
202
+ legacy = _adhoc_dir(repo) / "latest.json"
203
+ if not legacy.is_file():
204
+ return None
205
+ try:
206
+ os.replace(legacy, path)
207
+ except OSError:
208
+ return None
179
209
  try:
180
- return json.loads(path.read_text(encoding="utf-8"))
210
+ data = json.loads(path.read_text(encoding="utf-8"))
181
211
  except (OSError, json.JSONDecodeError):
182
212
  return None
213
+ return data if isinstance(data, dict) else None
214
+
215
+
216
+ def move_branch(repo: Path, branch: str, commit_sha: str, base_sha: str) -> str:
217
+ """Point `branch` at `commit_sha`. Return "" on success, else why it was refused.
218
+
219
+ An existing branch is moved only when its tip is merged into the base, equals the new commit, or
220
+ is the tip this session recorded for it. Otherwise it holds work that moving would orphan.
221
+ """
222
+ tip = git_output(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
223
+ if tip and tip != commit_sha:
224
+ merged = subprocess.run(
225
+ ["git", "-C", str(repo), "merge-base", "--is-ancestor", tip, base_sha],
226
+ capture_output=True,
227
+ ).returncode == 0
228
+ state = read_latest(repo) or {}
229
+ own = state.get("branch") == branch and state.get("commit_sha") == tip
230
+ if not merged and not own:
231
+ return (f"branch '{branch}' has commits not merged into {base_sha[:7]} "
232
+ f"(tip {tip[:7]}); refusing to move it. Delete or merge it first.")
233
+ res = subprocess.run(
234
+ ["git", "-C", str(repo), "branch", "-f", branch, commit_sha],
235
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
236
+ )
237
+ if res.returncode != 0:
238
+ return f"branch update failed: {res.stderr.strip()}"
239
+ return ""
183
240
 
184
241
 
185
242
  # Tool caches every gate run regenerates. They are the only ignored files a worktree may be
@@ -355,7 +412,7 @@ def run_adhoc(
355
412
  }
356
413
  if len(kept_paths) > 1:
357
414
  state["kept_worktrees"] = [str(path) for path in kept_paths]
358
- adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
415
+ write_latest(repo, state)
359
416
  return EXIT_FAIL
360
417
 
361
418
  worktree_str = packet.get("worktree")
@@ -468,20 +525,14 @@ def run_adhoc(
468
525
 
469
526
  if not reason:
470
527
  try:
471
- branch_res = subprocess.run(
472
- ["git", "-C", str(repo), "branch", "-f", adhoc_branch, commit_sha],
473
- capture_output=True, text=True, encoding="utf-8", errors="replace",
474
- )
475
- if branch_res.returncode != 0:
476
- reason = f"branch update failed: {branch_res.stderr.strip()}"
477
- else:
528
+ reason = move_branch(repo, adhoc_branch, commit_sha, base_sha)
529
+ if not reason:
478
530
  # An OSError from the helper (git not runnable) lands in the `except OSError` below.
479
531
  problem = caddis_worktree_safety.safe_remove_worktree(repo, wt_path)
480
532
  if problem or wt_path.exists():
481
533
  reason = problem or f"worktree remove failed: {wt_path}"
482
534
  else:
483
- kept_worktree = None
484
- subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
535
+ kept_worktree = None # `git worktree remove` dropped its registration; no prune (robustness #19)
485
536
  attempt_branch = packet.get("branch")
486
537
  if attempt_branch and attempt_branch != adhoc_branch:
487
538
  subprocess.run(
@@ -503,7 +554,7 @@ def run_adhoc(
503
554
  diff_stat = git_output(repo, "diff", "--shortstat", base_sha, diff_ref)
504
555
  diff_names = verified_names or changed_files
505
556
 
506
- # Save state to latest.json
557
+ # Save this session's state (latest-<session>.json)
507
558
  state = {
508
559
  "slug": slug,
509
560
  "title": title,
@@ -525,7 +576,7 @@ def run_adhoc(
525
576
  "plan_path": plan_rel,
526
577
  "created": datetime.now(timezone.utc).isoformat(),
527
578
  }
528
- adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
579
+ write_latest(repo, state)
529
580
 
530
581
  # Render Verdict Card
531
582
  is_success = not reason and kept_worktree is None
@@ -618,13 +669,8 @@ def drop_adhoc(repo: Path) -> int:
618
669
  if problem:
619
670
  print(f"[mmr] Could not remove kept worktree: {problem}")
620
671
  return EXIT_FAIL
621
- pruned = subprocess.run(
622
- ["git", "-C", str(repo), "worktree", "prune"],
623
- capture_output=True, text=True, encoding="utf-8", errors="replace",
624
- )
625
- if pruned.returncode != 0:
626
- print(f"[mmr] Could not prune worktrees: {pruned.stderr.strip()}")
627
- return EXIT_FAIL
672
+ # No `git worktree prune` here: it drops the registrations of every other worktree whose folder is missing
673
+ # for the moment, and `git worktree remove` has already dropped this one (robustness #19).
628
674
 
629
675
  if branch:
630
676
  subprocess.run(["git", "-C", str(repo), "branch", "-D", branch], capture_output=True)
@@ -209,11 +209,10 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
209
209
  caddis_worktree_safety.safe_remove_worktree(main, worktree, force_locked=True, env=roadmap._quiet_env())
210
210
  except OSError:
211
211
  pass
212
- for args in (("worktree", "prune"), ("branch", "-D", branch)):
213
- try:
214
- _git(main, *args, check=False)
215
- except (roadmap.RoadmapError, OSError):
216
- pass
212
+ try: # no `git worktree prune`: it would drop other tools' registrations too (robustness #19)
213
+ _git(main, "branch", "-D", branch, check=False)
214
+ except (roadmap.RoadmapError, OSError):
215
+ pass
217
216
  if worktree.exists():
218
217
  caddis_worktree_safety.remove_tree(worktree)
219
218
 
@@ -234,6 +233,7 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
234
233
  out("PR: none")
235
234
  return caddis_exit.CLEAN
236
235
  pushed = False
236
+ pushed_tip = ""
237
237
  try:
238
238
  gh_repo = roadmap.gh_repo_of(main)
239
239
  if gh_repo is None:
@@ -242,14 +242,15 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
242
242
  _git(worktree, "commit", "--quiet", "-m", title)
243
243
  _git(worktree, "push", "--quiet", "-u", "origin", branch)
244
244
  pushed = True
245
+ pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the only tip we may delete
245
246
  url = open_pr(gh_repo, branch, base, title, body)
246
247
  except (DocsPrError, roadmap.RoadmapError, OSError) as exc:
247
248
  out(f"docs PR failed: {exc}")
248
249
  out(f"PATCH: {patch}")
249
250
  if pushed:
250
- try:
251
- _git(main, "push", "--quiet", "origin", "--delete", branch, check=False)
252
- except (roadmap.RoadmapError, OSError):
251
+ try: # only while the remote tip is still the commit we pushed
252
+ _git(main, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
253
+ except (roadmap.RoadmapError, OSError, ValueError):
253
254
  pass
254
255
  _remove(main, worktree, branch)
255
256
  return getattr(exc, "code", caddis_exit.NOT_RUN)
@@ -275,7 +275,6 @@ def remove_phase_worktree(
275
275
  problem = f"worktree remove left {resolved} on disk"
276
276
  if problem:
277
277
  raise LaneConfigError(problem)
278
- _git_output(repo_root, "worktree", "prune")
279
278
  if delete_branch:
280
279
  _git_output(repo_root, "branch", "-D", "--", wt.branch)
281
280
 
@@ -1266,7 +1265,10 @@ def child_env(base: dict, lane: str, model: str) -> dict:
1266
1265
  for name in LANE_SECRET_VARS:
1267
1266
  env.pop(name, None)
1268
1267
  env.update(LANE_NO_PROMPT_ENV)
1269
- env["CADDIS_HEADLESS"] = "1"
1268
+ try:
1269
+ env = caddis_spend.bump_headless_depth(env) # sets CADDIS_HEADLESS=1 and the depth (robustness #19)
1270
+ except caddis_spend.HeadlessDepthExceeded as exc:
1271
+ raise LaneConfigError(str(exc)) from exc
1270
1272
  if lane == "glm":
1271
1273
  env["OSS_MODEL"] = model
1272
1274
  return env
@@ -684,6 +684,16 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
684
684
  # there is no sandbox on native Windows. The diff comes in the prompt (`_diff_material`).
685
685
  # No `--allowedTools`: unscoped, it approves files outside the worktree (smoke run 18 Oct 2026: without it a
686
686
  # Read in the worktree ran, a Read of ~/.caddis/spend-cap.toml was refused).
687
+ try:
688
+ import caddis_spend # the recursion guard lives beside the spend cap (robustness #19)
689
+ env_depth = caddis_spend.bump_headless_depth(os.environ)
690
+ except ImportError:
691
+ env_depth = None
692
+ except RuntimeError as exc: # HeadlessDepthExceeded
693
+ output = f"{_PREFIX}{exc}\n"
694
+ out.parent.mkdir(parents=True, exist_ok=True)
695
+ out.write_text(output, encoding="utf-8")
696
+ return output
687
697
  exe = _claude_exe()
688
698
  if exe is None:
689
699
  output = f"{_PREFIX}claude could not start: not found on PATH (the current folder is not searched)\n"
@@ -697,11 +707,21 @@ def launch_claude(prompt: str, worktree: Path, out: Path) -> str:
697
707
  governor = default_governor()
698
708
  if governor:
699
709
  cmd = governor + cmd
710
+ elif os.environ.get("CADDIS_ALLOW_UNGOVERNED") == "1": # the owner's explicit opt-out
711
+ print("WARNING: caddis_governor.py not found; the review runs without a CPU and memory cap "
712
+ "(CADDIS_ALLOW_UNGOVERNED=1)", file=sys.stderr)
700
713
  else:
701
- print("WARNING: caddis_governor.py not found; the review runs without a CPU and memory cap", file=sys.stderr)
714
+ # The plan reviewer needs the governor, as the roadmap runner does (robustness #19): an ungoverned
715
+ # `claude -p` can fill the machine's CPU and memory, and a review that cannot run is only NOT_RUN.
716
+ output = (f"{_PREFIX}caddis_governor.py not found, so the review is not started without a CPU and memory "
717
+ "cap (set CADDIS_ALLOW_UNGOVERNED=1 to run it ungoverned)\n")
718
+ out.parent.mkdir(parents=True, exist_ok=True)
719
+ out.write_text(output, encoding="utf-8")
720
+ return output
702
721
  # CADDIS_HEADLESS keeps relay and session-end hooks quiet (claude-oss.ps1 sets it too).
703
722
  # No program from the current folder (Windows searches it first), no git index rewrite by the reviewer's tools.
704
- env = {**os.environ, "CADDIS_HEADLESS": "1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
723
+ env = {**(env_depth if env_depth is not None else os.environ), "CADDIS_HEADLESS": "1",
724
+ "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
705
725
  "NoDefaultCurrentDirectoryInExePath": "1", "GIT_OPTIONAL_LOCKS": "0"}
706
726
  # Its own process group (Windows) or session (POSIX), so a timeout kills the whole tree, not only the top process.
707
727
  group = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt"
@@ -148,12 +148,19 @@ OUTPUT_MASKS: list[tuple[re.Pattern, str]] = [
148
148
  (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "***"), # secret_filter github-token
149
149
  (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "***"), # secret_filter slack-token
150
150
  (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "***"), # secret_filter jwt
151
- # secret_filter assignment, widened: a quoted value may hold spaces (a passphrase of several words).
152
- (re.compile(r"(?i)([A-Za-z0-9_]*(?:password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*)"
151
+ # secret_filter assignment, widened: a quoted value may hold spaces (a passphrase of several words). A match
152
+ # starts only at the start of a word: a match inside a word always has one from the word start too, and without
153
+ # the lookbehind each character of a long word started a new scan (quadratic: 50 KB of one word took 64 s).
154
+ (re.compile(r"(?i)(?<![A-Za-z0-9_])"
155
+ r"([A-Za-z0-9_]*(?:password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*\s*[:=]\s*)"
153
156
  r"(['\"])[^'\"\n]{8,}\2"), r"\1\2***\2"),
154
157
  (re.compile(r"(?im)^(\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
155
158
  r"[A-Za-z0-9_]*\s*=\s*)[^\s'\"#]{8,}"), r"\1***"), # secret_filter env-assignment
156
- (re.compile(r"\b([A-Za-z][A-Za-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"), # URL credentials
159
+ # URL credentials. The scheme starts at a letter after a word boundary. Every such letter in one run of scheme
160
+ # characters reaches the same `://`, so only the first one is tried: group 1 skips, possessively, the run's
161
+ # digits, `+.-` and letters that follow a word character. Without that, `a.a.a.a...` was quadratic.
162
+ (re.compile(r"(?<![A-Za-z0-9+.-])((?:[0-9+.-]|(?<=\w)[A-Za-z])*+)([A-Za-z][A-Za-z0-9+.-]*://)"
163
+ r"[^/\s:@]+:[^/\s@]+@"), r"\1\2***@"),
157
164
  # Absolute paths, which can name a user or a host. A path may hold spaces, so each one runs to the end of the
158
165
  # line or a quote; a Windows drive or UNC path also stops at `<`, `>` or `|` (never part of a Windows path).
159
166
  (re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\r\n'\"<>|]*"), "<path>"), # a Windows drive path
@@ -1072,7 +1079,6 @@ def merged_checkout(root: Path, branch: str):
1072
1079
  yield worktree
1073
1080
  finally:
1074
1081
  caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1075
- _git(root, "worktree", "prune", check=False)
1076
1082
  caddis_worktree_safety.remove_tree(tmp) # links first: a junction's target survives
1077
1083
 
1078
1084
 
@@ -1822,11 +1828,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1822
1828
  _git(worktree, "add", "--", path)
1823
1829
  _git(worktree, "commit", "--quiet", "-m", title)
1824
1830
  _git(worktree, "push", "--quiet", "-u", "origin", branch)
1831
+ pushed_tip = _git(worktree, "rev-parse", "HEAD").stdout.strip() # the tip we pushed: the only one we delete
1825
1832
  try:
1826
1833
  pr = open_pr(root, gh_repo, branch, snap.branch, title,
1827
1834
  f"Roadmap change by caddis_roadmap.py: {slug} {mid} {change}.")
1828
1835
  except Exception:
1829
- _git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
1836
+ _git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
1830
1837
  raise
1831
1838
  try:
1832
1839
  rc = merge_pr(root, pr, gh_repo)
@@ -1834,13 +1841,12 @@ def write_change(root: Path, slug: str, mid: str, change: str, edit, dry_run: bo
1834
1841
  merge_exit = "timeout"
1835
1842
  raise
1836
1843
  out(f"#{pr} {title}: " + ("merged" if rc == caddis_exit.CLEAN else f"not merged (merge chain exit {rc})"))
1837
- if rc == caddis_exit.CLEAN:
1838
- _git(root, "push", "--quiet", "origin", "--delete", branch, check=False)
1844
+ if rc == caddis_exit.CLEAN: # only while the remote tip is still the commit we pushed
1845
+ _git(root, *caddis_worktree_safety.remote_delete_args(branch, pushed_tip), check=False)
1839
1846
  else:
1840
1847
  rc = caddis_exit.NOT_RUN if rc == caddis_exit.NOT_RUN else caddis_exit.BLOCKED
1841
1848
  finally:
1842
1849
  caddis_worktree_safety.safe_remove_worktree(root, worktree, force_locked=True, env=_quiet_env())
1843
- _git(root, "worktree", "prune", check=False)
1844
1850
  _git(root, "branch", "-D", branch, check=False)
1845
1851
  caddis_worktree_safety.remove_tree(tmp)
1846
1852
  _audit(root, slug, {"utc": _utc(), "roadmap": slug, "id": mid, "change": change, "branch": branch,
@@ -200,6 +200,8 @@ def headless_env(base: dict | None = None) -> dict:
200
200
  console, so every session and every caddis_roadmap.py call the runner starts carries the marker."""
201
201
  env = dict(os.environ if base is None else base)
202
202
  env.update(HEADLESS_ENV)
203
+ # One level deeper for every session it starts. main() refuses to start past the limit (robustness #19).
204
+ env[caddis_spend.HEADLESS_DEPTH_VAR] = str(caddis_spend.headless_depth(env) + 1)
203
205
  return env
204
206
 
205
207
 
@@ -223,12 +225,19 @@ def runner_env() -> dict:
223
225
 
224
226
 
225
227
  def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_min: float,
226
- governor: Sequence[str], engine_cmd: Sequence[str] | None = None) -> list[str]:
228
+ governor: Sequence[str], engine_cmd: Sequence[str] | None = None,
229
+ worktree: Path | str | None = None) -> list[str]:
227
230
  """The full command line for one session: the governor prefix, then the engine's print-mode call.
228
231
 
229
232
  `claude` gets its prompt on stdin, not as an argument (PROMPT_ON_STDIN): on Windows `claude` is an npm `.cmd`
230
233
  shim, and cmd.exe cuts a multi-line argument at its first newline, which drops the rest of the prompt and every
231
- argument after it."""
234
+ argument after it.
235
+
236
+ Every engine gets its permission mode on the command line (robustness #19), none is left to a settings file or
237
+ a default that a new version may change: `claude` bypassPermissions (with the deny rules), `agy` accept-edits
238
+ plus --dangerously-skip-permissions (as the lanes do; a print session cannot answer a prompt), `codex` the
239
+ workspace-write sandbox confined to `worktree` with `-C` (as the lanes do; the session's own folder, default
240
+ the current one). The boundary is the same for all three: no publish token (session_env) and the guard."""
232
241
  if engine not in ENGINES:
233
242
  raise ValueError(f"unknown engine {engine!r}; known: {', '.join(ENGINES)}")
234
243
  exe = list(engine_cmd) if engine_cmd else [engine]
@@ -240,10 +249,10 @@ def build_command(engine: str, model: str, prompt: str, *, usd: float, timeout_m
240
249
  tail = [*exe, "-p", "--disallowedTools", *SESSION_DENY_RULES, "--permission-mode", "bypassPermissions",
241
250
  "--model", model, "--output-format", "json", "--max-budget-usd", _usd(usd)]
242
251
  elif engine == "agy": # probe 5: --model must come before --print
243
- tail = [*exe, "--model", model, "--print", prompt, "--output-format", "json",
244
- "--print-timeout", f"{max(1, int(timeout_min))}m"]
252
+ tail = [*exe, "--model", model, "--mode", "accept-edits", "--dangerously-skip-permissions",
253
+ "--print", prompt, "--output-format", "json", "--print-timeout", f"{max(1, int(timeout_min))}m"]
245
254
  else:
246
- tail = [*exe, "exec", "--model", model, "--json", prompt]
255
+ tail = [*exe, "exec", "--model", model, "-s", "workspace-write", "-C", str(worktree or "."), "--json", prompt]
247
256
  return [*governor, *tail]
248
257
 
249
258
 
@@ -888,7 +897,7 @@ def _one_session(ctx: _Ctx, prompt: str, relay_slug: str, n: int, spent: float,
888
897
  remaining_s = max(1.0, min(caps.session_timeout_min * 60, caps.max_hours * 3600 - elapsed))
889
898
  usd = min(caps.max_usd_per_session, caps.max_usd_total - spent)
890
899
  cmd = build_command(ctx.engine, ctx.model, prompt, usd=usd, timeout_min=remaining_s / 60, governor=ctx.governor,
891
- engine_cmd=ctx.engine_cmd)
900
+ engine_cmd=ctx.engine_cmd, worktree=cwd)
892
901
  rid = _spend_reserve(ctx.home, spend_slug, ctx.now(), n > 1) if ctx.engine == "agy" else None
893
902
  launched, outcome = ctx.clock(), None
894
903
  try:
@@ -1273,7 +1282,8 @@ def _render_page(ctx: "_Ctx", roadmap_cmd: Sequence[str]) -> None:
1273
1282
  def _dry_run_tail(ctx: _Ctx, template: Path, prompt: str, cwd: Path | None = None) -> None:
1274
1283
  caps = ctx.caps
1275
1284
  cmd = build_command(ctx.engine, ctx.model, "<resume prompt>", usd=min(caps.max_usd_per_session, caps.max_usd_total),
1276
- timeout_min=caps.session_timeout_min, governor=ctx.governor, engine_cmd=ctx.engine_cmd)
1285
+ timeout_min=caps.session_timeout_min, governor=ctx.governor, engine_cmd=ctx.engine_cmd,
1286
+ worktree=cwd or ctx.repo)
1277
1287
  print(f"would launch (cwd {cwd or ctx.repo}, CADDIS_HEADLESS=1): {subprocess.list2cmdline(cmd)}"
1278
1288
  + (" (resume prompt on stdin)" if ctx.engine in PROMPT_ON_STDIN else ""))
1279
1289
  print(f"resume prompt from {template}:\n{prompt}")
@@ -1552,7 +1562,10 @@ def ensure_worktree(repo: Path, slug: str, default: str, root: Path | None = Non
1552
1562
  if path.resolve() in _registered_worktrees(repo):
1553
1563
  if path.is_dir():
1554
1564
  return path
1555
- _git_out(repo, "worktree", "prune") # listed, but its folder is gone (temp cleanup): prune, make it again
1565
+ # Listed, but its folder is gone (temp cleanup): drop this one registration and make it again. Not
1566
+ # `git worktree prune`: that would drop every other tool's registration with a missing folder too.
1567
+ if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
1568
+ raise _Stop(f"FAILED cannot drop the registration of the missing worktree {path}", caddis_exit.BLOCKED)
1556
1569
  if path.exists():
1557
1570
  raise _Stop(f"FAILED worktree path {path} exists and is not a worktree of this repo", caddis_exit.BLOCKED)
1558
1571
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -1586,7 +1599,6 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
1586
1599
  return f"worktree kept (not clean, {len(work)} change(s)): {path}"
1587
1600
  if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
1588
1601
  return f"worktree kept (remove failed): {path}"
1589
- _git_out(repo, "worktree", "prune")
1590
1602
  _state_file(repo, slug, "worktree").unlink(missing_ok=True)
1591
1603
  return f"worktree removed: {path}"
1592
1604
 
@@ -2660,6 +2672,11 @@ def main(argv: list[str] | None = None) -> int:
2660
2672
  ok, why = last_stop_restartable(args.repo, args.roadmap)
2661
2673
  print(why)
2662
2674
  return caddis_exit.CLEAN if ok else caddis_exit.BLOCKED
2675
+ try:
2676
+ caddis_spend.bump_headless_depth(os.environ) # a check only: the sessions get the bump in headless_env
2677
+ except caddis_spend.HeadlessDepthExceeded as exc:
2678
+ print(f"caddis_roadmap_run: {exc}", file=sys.stderr)
2679
+ return caddis_exit.BLOCKED
2663
2680
  caps = Caps(args.max_hours, args.max_sessions, args.session_timeout_min, args.max_usd_per_session,
2664
2681
  args.max_usd_total)
2665
2682
  return run(repo=args.repo, plan=args.plan, roadmap=args.roadmap, caps=caps, engine=args.engine, model=args.model,
@@ -258,14 +258,32 @@ CONTEXT_NOTE_ABOVE = 85.0
258
258
  CONTEXT_MAX_AGE_S = 900
259
259
 
260
260
 
261
- def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
261
+ SESSION_ID_ENV = ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CADDIS_SESSION_ID")
262
+
263
+
264
+ def _session_id(explicit: str | None = None) -> str:
265
+ """This session's id: the explicit one, else the first of SESSION_ID_ENV that is set. Made file-name safe."""
266
+ raw = explicit or next((os.environ[v] for v in SESSION_ID_ENV if os.environ.get(v)), "")
267
+ return "".join(ch if (ch.isalnum() and ch.isascii()) or ch in "-_" else "_" for ch in raw)[:64].strip(".")
268
+
269
+
270
+ def read_context_pct(repo_root: Path, session_id: str | None = None) -> tuple[float | None, str]:
262
271
  """(percentage, provenance) from the statusline's cache, or (None, why not).
263
272
 
273
+ With a session id (the argument, or CLAUDE_SESSION_ID and the like) it reads that session's own file,
274
+ `.caddis/context-window/<id>.json`, so a second session in the same folder cannot change the figure.
275
+ Without one it reads the shared `context-window.json`, which any session may have written last; the
276
+ provenance then says "shared".
277
+
264
278
  Written by `caddis_statusline.record_context`. Claude Code gives the figure to the status
265
279
  line and to nothing else, so this file is the only runtime-sourced number a plain command
266
280
  can reach.
267
281
  """
268
- target = repo_root / ART / "context-window.json"
282
+ sid = _session_id(session_id)
283
+ shared = repo_root / ART / "context-window.json"
284
+ target = repo_root / ART / "context-window" / f"{sid}.json" if sid else shared
285
+ if sid and not target.is_file():
286
+ target, sid = shared, "" # the status line has not written this session's file yet (or is old)
269
287
  try:
270
288
  raw = json.loads(target.read_text(encoding="utf-8"))
271
289
  pct = float(raw["used_percentage"])
@@ -275,8 +293,10 @@ def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
275
293
  + f"{ART}/context-window.json — run `/caddis:install-statusline` to install the "
276
294
  "status line that writes it")
277
295
  if age > CONTEXT_MAX_AGE_S:
278
- return pct, f"runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
279
- return pct, f"runtime figure, {age}s old"
296
+ scope = "this session's" if sid else "shared (session unknown)"
297
+ return pct, f"{scope} runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
298
+ scope = "this session's" if sid else "shared (session unknown)"
299
+ return pct, f"runtime figure, {scope}, {age}s old"
280
300
 
281
301
 
282
302
  @dataclass