@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.
@@ -38,8 +38,10 @@ CLI:
38
38
  python caddis_spend.py status prints the committed spend of the last 24 h
39
39
  python caddis_spend.py run --caller X -- agy ...
40
40
  reserve, run, settle. Returns agy's own exit code, or 75 over the cap / could not lock,
41
- or 78 not configured. 75 and 78 are never agy's own codes for a refusal, so a caller can
42
- tell "the cap refused" from "agy failed". Each refusal also prints a `caddis_spend:` line.
41
+ or 78 not configured, or 124 when the command ran past its time limit (--timeout-s, else
42
+ CADDIS_SPEND_RUN_TIMEOUT_S, else 3600 s) and its process tree was killed. 75 and 78 are never
43
+ agy's own codes for a refusal, so a caller can tell "the cap refused" from "agy failed". Each
44
+ refusal also prints a `caddis_spend:` line.
43
45
  """
44
46
 
45
47
  from __future__ import annotations
@@ -62,12 +64,51 @@ EXIT_OVER_CAP = 1 # `check` only
62
64
  EXIT_NOT_CONFIGURED = 3 # `check` only
63
65
  RUN_REFUSED = 75 # `run`: over the cap, or could not lock (EX_TEMPFAIL)
64
66
  RUN_NOT_CONFIGURED = 78 # `run`: not configured (EX_CONFIG)
67
+ RUN_TIMEOUT = 124 # `run`: the child ran past its time limit and its process tree was killed (as timeout(1))
68
+ DEFAULT_RUN_TIMEOUT_S = 3600.0 # one hour: a run over about 1 hour needs the owner's approval anyway
69
+ # No console window for the child on Windows (robustness #19; see no_window.py: never DETACHED_PROCESS).
70
+ _NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
65
71
  LOCK_TIMEOUT_S = 10.0
66
72
  NOT_CONFIGURED_MESSAGE = ("caddis_spend: spend cap not configured: set eur_per_24h, reserve_eur_per_call and both "
67
73
  "token prices (finite, above 0) in ~/.caddis/spend-cap.toml; every agy launch is refused "
68
74
  "until then")
69
75
 
70
76
 
77
+ # Recursion guard on headless starts (robustness #19). Every launcher of a headless caddis session passes
78
+ # CADDIS_HEADLESS_DEPTH one higher than it got, and refuses to launch once the depth is at the limit, so a
79
+ # headless session that starts a headless session that starts one... ends after MAX_HEADLESS_DEPTH levels.
80
+ # The owner may change the limit with CADDIS_HEADLESS_MAX_DEPTH. This stops accidents and loops; a hostile
81
+ # process can reset the variable, like the cap file (see the Limits above).
82
+ HEADLESS_DEPTH_VAR = "CADDIS_HEADLESS_DEPTH"
83
+ MAX_HEADLESS_DEPTH = 3
84
+
85
+
86
+ class HeadlessDepthExceeded(RuntimeError):
87
+ """A headless start was refused because headless sessions are already nested too deep."""
88
+
89
+
90
+ def headless_depth(env) -> int:
91
+ """How many headless launches lie above this process: CADDIS_HEADLESS_DEPTH, or 0 when unset or not a count."""
92
+ raw = str(env.get(HEADLESS_DEPTH_VAR, "")).strip()
93
+ return int(raw) if raw.isdigit() else 0
94
+
95
+
96
+ def bump_headless_depth(env) -> dict:
97
+ """A copy of `env` for a child headless session: CADDIS_HEADLESS=1 and the depth one higher.
98
+ Raises HeadlessDepthExceeded when the depth is already at the limit."""
99
+ limit_raw = str(env.get("CADDIS_HEADLESS_MAX_DEPTH", "")).strip()
100
+ limit = int(limit_raw) if limit_raw.isdigit() and int(limit_raw) > 0 else MAX_HEADLESS_DEPTH
101
+ depth = headless_depth(env)
102
+ if depth >= limit:
103
+ raise HeadlessDepthExceeded(
104
+ f"caddis: headless depth {depth} is at the limit ({limit}); not starting another headless session "
105
+ f"({HEADLESS_DEPTH_VAR}; the owner can raise CADDIS_HEADLESS_MAX_DEPTH)")
106
+ out = dict(env)
107
+ out[HEADLESS_DEPTH_VAR] = str(depth + 1)
108
+ out["CADDIS_HEADLESS"] = "1"
109
+ return out
110
+
111
+
71
112
  class NotConfigured(Exception):
72
113
  """The cap file is missing, unreadable, or holds a value that is not a finite number above 0."""
73
114
 
@@ -317,9 +358,67 @@ def result_text(stdout: str) -> str:
317
358
  return stdout
318
359
 
319
360
 
320
- def run(argv: list[str], caller: str, home: Path) -> int:
321
- """Reserve, run agy, settle. Returns agy's exit code, or RUN_REFUSED (75) / RUN_NOT_CONFIGURED (78)."""
361
+ def run_timeout_s() -> float:
362
+ """The wall-clock limit of one `run` child: CADDIS_SPEND_RUN_TIMEOUT_S, else DEFAULT_RUN_TIMEOUT_S.
363
+ A value that is not a finite number above 0 is ignored."""
364
+ try:
365
+ value = float(os.environ.get("CADDIS_SPEND_RUN_TIMEOUT_S", ""))
366
+ except ValueError:
367
+ return DEFAULT_RUN_TIMEOUT_S
368
+ return value if math.isfinite(value) and value > 0 else DEFAULT_RUN_TIMEOUT_S
369
+
370
+
371
+ def _kill_tree(proc: subprocess.Popen) -> None:
372
+ """Kill the child and everything it started (taskkill /T on Windows, the process group on POSIX)."""
373
+ try:
374
+ if os.name == "nt":
375
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True, timeout=15,
376
+ **_NO_WINDOW_KW)
377
+ else:
378
+ import signal
379
+ os.killpg(proc.pid, signal.SIGKILL)
380
+ except Exception:
381
+ pass
382
+ try:
383
+ proc.kill()
384
+ except Exception:
385
+ pass
386
+
387
+
388
+ 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."""
391
+ 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",
393
+ errors="replace", env=env, **_NO_WINDOW_KW, **group)
394
+ try:
395
+ out, err = proc.communicate(timeout=timeout_s)
396
+ return proc.returncode, out or "", err or ""
397
+ except subprocess.TimeoutExpired:
398
+ _kill_tree(proc)
399
+ try: # the pipes close once the tree is dead; do not wait for ever if a stray holder survives
400
+ out, err = proc.communicate(timeout=10)
401
+ except Exception:
402
+ out, err = "", ""
403
+ return None, out or "", err or ""
404
+ except BaseException:
405
+ # Ctrl-C or any other error while waiting: the child (agy, spending) must not outlive us. It runs in its
406
+ # own session/group, so it does not get our SIGINT. Kill the tree, then let the exception go on; run()
407
+ # settles the reserve in its `finally`.
408
+ _kill_tree(proc)
409
+ raise
410
+
411
+
412
+ def run(argv: list[str], caller: str, home: Path, timeout_s: float | None = None) -> int:
413
+ """Reserve, run agy, settle. Returns agy's exit code, or RUN_REFUSED (75) / RUN_NOT_CONFIGURED (78) /
414
+ RUN_TIMEOUT (124: the child ran past `timeout_s`, default run_timeout_s(); its process tree was killed and the
415
+ call is booked at its reserve)."""
322
416
  now = lambda: datetime.now(timezone.utc) # noqa: E731
417
+ try:
418
+ env = bump_headless_depth(os.environ) # before the reserve: a refused start costs nothing
419
+ except HeadlessDepthExceeded as exc:
420
+ print(str(exc), file=sys.stderr)
421
+ return RUN_REFUSED
323
422
  try:
324
423
  rid = reserve(home, caller, now())
325
424
  except NotConfigured:
@@ -334,19 +433,25 @@ def run(argv: list[str], caller: str, home: Path) -> int:
334
433
  if rid is None:
335
434
  print(f"caddis_spend: over cap: agy refused ({status_line(home, now())})", file=sys.stderr)
336
435
  return RUN_REFUSED
337
- env = {**os.environ, "CADDIS_HEADLESS": "1"}
338
436
  out = ""
339
437
  counts: tuple[int, int] | None = None
340
438
  started = time.monotonic()
341
439
  try:
342
- done = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)
343
- out = done.stdout or ""
440
+ limit = run_timeout_s() if timeout_s is None else timeout_s
441
+ returncode, out, err = _run_child(argv, env, limit)
442
+ if returncode is None: # timed out: whatever it printed is passed on, but no token count is trusted
443
+ sys.stdout.write(out)
444
+ sys.stderr.write(err)
445
+ print(f"caddis_spend: {argv[0]} timed out after {limit:g}s; its process tree was killed "
446
+ f"(booked at the reserve). Set CADDIS_SPEND_RUN_TIMEOUT_S or --timeout-s to change the limit.",
447
+ file=sys.stderr)
448
+ return RUN_TIMEOUT
344
449
  # Tokens only from JSON mode: in text mode the model's own lines could pose as a usage document.
345
450
  if json_mode(argv):
346
451
  counts = tokens_from_output(out)
347
452
  sys.stdout.write(out)
348
- sys.stderr.write(done.stderr or "")
349
- return done.returncode
453
+ sys.stderr.write(err)
454
+ return returncode
350
455
  except FileNotFoundError:
351
456
  counts = (0, 0) # nothing ran, nothing was spent
352
457
  print(f"caddis_spend: command not found: {argv[0]}", file=sys.stderr)
@@ -380,6 +485,9 @@ def main(argv: list[str] | None = None) -> int:
380
485
  sub.add_parser("status")
381
486
  p_run = sub.add_parser("run")
382
487
  p_run.add_argument("--caller", required=True)
488
+ p_run.add_argument("--timeout-s", type=float, default=None,
489
+ help="kill the command's process tree after this many seconds "
490
+ "(default: CADDIS_SPEND_RUN_TIMEOUT_S, else 3600)")
383
491
  args = parser.parse_args(raw)
384
492
  home = Path.home()
385
493
  now = datetime.now(timezone.utc)
@@ -408,7 +516,9 @@ def main(argv: list[str] | None = None) -> int:
408
516
  return EXIT_OK
409
517
  if not tail:
410
518
  parser.error("run needs the agy command after --")
411
- return run(tail, args.caller, home)
519
+ if args.timeout_s is not None and not (math.isfinite(args.timeout_s) and args.timeout_s > 0):
520
+ parser.error("--timeout-s must be a number above 0")
521
+ return run(tail, args.caller, home, timeout_s=args.timeout_s)
412
522
 
413
523
 
414
524
  if __name__ == "__main__":
@@ -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:
@@ -294,17 +294,22 @@ def close_worktree(
294
294
  # Someone pushed after the merge: that work is not in main. Keep the remote branch.
295
295
  print(f"kept origin/{branch_name}: its tip {remote_tip[:12]} is not contained in the merged PR head {str(head_ref_oid)[:12]}")
296
296
  elif remote_tip:
297
- cmd_push_del = ["git", "-C", str(repo), "push", "-q", "origin", "--delete", branch_name]
297
+ # The check above is followed by a compare-and-delete on the server: a push that lands in between wins.
298
+ cmd_push_del = ["git", "-C", str(repo), *caddis_worktree_safety.remote_delete_args(branch_name, remote_tip)]
298
299
  proc = subprocess.run(cmd_push_del, capture_output=True, text=True, encoding="utf-8", errors="replace")
299
300
  if proc.returncode != 0:
300
- print(f"failed command: {' '.join(cmd_push_del)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
301
- return caddis_exit.ERROR
302
-
303
- cmd_prune = ["git", "-C", str(repo), "worktree", "prune"]
304
- proc = subprocess.run(cmd_prune, capture_output=True, text=True, encoding="utf-8", errors="replace")
305
- if proc.returncode != 0:
306
- print(f"failed command: {' '.join(cmd_prune)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
307
- return caddis_exit.ERROR
301
+ # A push that landed after our check makes the server refuse the compare-and-delete. That is the same
302
+ # outcome as the "kept" path above, not an error: look at the remote again.
303
+ again = subprocess.run(cmd_ls, capture_output=True, text=True, encoding="utf-8", errors="replace")
304
+ new_tip = again.stdout.split()[0] if again.returncode == 0 and again.stdout.split() else ""
305
+ if new_tip and new_tip != remote_tip:
306
+ print(f"kept origin/{branch_name}: its tip moved from {remote_tip[:12]} to {new_tip[:12]} while it was being deleted")
307
+ else:
308
+ print(f"failed command: {' '.join(cmd_push_del)}\n{proc.stderr.strip() or proc.stdout.strip()}", file=sys.stderr)
309
+ return caddis_exit.ERROR
310
+
311
+ # No `git worktree prune`: `git worktree remove` (above) dropped this registration, and a prune would also drop
312
+ # the registrations of other tools whose folders are missing for the moment (robustness #19).
308
313
 
309
314
  print(f"removed {worktree} ({branch_name}); merge {merge_oid}; files compared: {files_compared}")
310
315
  return caddis_exit.CLEAN
@@ -129,6 +129,18 @@ def _worktree_blocks(porcelain: str) -> list[list[str]]:
129
129
  return blocks
130
130
 
131
131
 
132
+ def remote_delete_args(branch: str, expected_tip: str, remote: str = "origin") -> tuple[str, ...]:
133
+ """The `git` arguments that delete `remote/<branch>` only while its tip is `expected_tip` (a full object id).
134
+
135
+ `--force-with-lease=<ref>:<oid>` makes the push a compare-and-delete in one step on the server: when someone
136
+ pushed more commits to the branch after we looked, git refuses and the branch stays (robustness #19). Raises
137
+ ValueError for anything but a 40 or 64 digit hex id, so a bad value cannot turn into a plain delete."""
138
+ tip = str(expected_tip or "").strip().lower()
139
+ if len(tip) not in (40, 64) or any(ch not in "0123456789abcdef" for ch in tip):
140
+ raise ValueError(f"not a full object id: {expected_tip!r}")
141
+ return ("push", "--quiet", f"--force-with-lease=refs/heads/{branch}:{tip}", remote, "--delete", branch)
142
+
143
+
132
144
  def safe_remove_worktree(repo: Path, worktree: Path, *, force: bool = True, force_locked: bool = False,
133
145
  git_flags: Sequence[str] = (), env: Mapping[str, str] | None = None) -> str | None:
134
146
  """Remove the linked worktree `worktree` of `repo`: its links first, then `git worktree remove`.
@@ -90,8 +90,19 @@ def record(hook: str, feature: str, exc: BaseException, root: str | None = None)
90
90
  "message": emsg,
91
91
  }, ensure_ascii=False))
92
92
 
93
- with open(path, "w", encoding="utf-8", newline="\n") as fh:
94
- fh.write("\n".join(lines[-_MAX_LINES:]) + "\n")
93
+ # Write beside the ledger, then swap in one step: a crash or a second hook mid-write
94
+ # leaves the old ledger whole, never a torn one. The temp name is per process.
95
+ tmp = f"{path}.{os.getpid()}.tmp"
96
+ try:
97
+ with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
98
+ fh.write("\n".join(lines[-_MAX_LINES:]) + "\n")
99
+ os.replace(tmp, path)
100
+ except BaseException:
101
+ try:
102
+ os.unlink(tmp)
103
+ except OSError:
104
+ pass
105
+ raise
95
106
  except Exception:
96
107
  return # the ledger must never be the thing that breaks a hook
97
108
 
@@ -101,11 +101,15 @@ SECRET_RULES: list[tuple[str, re.Pattern]] = [
101
101
  ("npm-token", re.compile(r"\bnpm_[A-Za-z0-9]{36}\b")),
102
102
  ("slack-webhook", re.compile(r"https://hooks\.slack\.com/services/[A-Za-z0-9_/]+")),
103
103
  # user:password@host in a URL. `value` is the password; placeholders are exempted in code.
104
+ # Both rules below start a match only at the start of a run of the characters their first part takes (the
105
+ # lookbehind). A match that starts inside the run finds the same value from the run start too, and without the
106
+ # lookbehind each character of a long word started a new scan: quadratic, minutes on one long line.
104
107
  ("url-credentials", re.compile(
105
- r"[a-z][a-z0-9+.-]*://[^/\s:@]*:(?P<value>[^/\s@]{3,})@", re.I)),
108
+ r"(?<![a-z0-9+.-])[0-9+.-]*[a-z][a-z0-9+.-]*://[^/\s:@]*:(?P<value>[^/\s@]{3,})@", re.I)),
106
109
  # `name = "v"`, `name: "v"`, and a quoted key: `"name": "v"`, `'name': 'v'`.
107
110
  ("assignment", re.compile(
108
- r"(?i)[A-Za-z0-9_]*(password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*['\"]?\s*[:=]\s*['\"](?P<value>[^'\"\s]{8,})['\"]"
111
+ r"(?i)(?<![A-Za-z0-9_])[A-Za-z0-9_]*(password|passwd|secret|api[_-]?key|token)[A-Za-z0-9_]*['\"]?\s*[:=]\s*"
112
+ r"['\"](?P<value>[^'\"\s]{8,})['\"]"
109
113
  )),
110
114
  ("env-assignment", re.compile(
111
115
  r"^\s*(?:export\s+)?[A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|API_KEY|APIKEY|TOKEN|PRIVATE_KEY)"
@@ -1207,6 +1207,8 @@ session-state.md
1207
1207
  session-state/
1208
1208
  context-window.json
1209
1209
  context-window.json.tmp
1210
+ context-window.json.*.tmp
1211
+ context-window/
1210
1212
  .session-state-*.tmp
1211
1213
  hook-errors.jsonl
1212
1214
  .relay-injected/
@@ -1420,6 +1422,18 @@ def _user_settings_path(home: Path | None = None) -> Path:
1420
1422
  return root / ".claude" / "settings.json"
1421
1423
 
1422
1424
 
1425
+ def _atomic_write_json(path: Path, data: dict) -> None:
1426
+ """Write `data` to `path` through a temp file. The temp name is unique per process: every
1427
+ SessionStart runs this (inject_relay.py), and two sessions starting at once must not share one."""
1428
+ tmp = path.with_name(f"{path.name}.caddis-tmp-{os.getpid()}")
1429
+ try:
1430
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1431
+ os.replace(tmp, path)
1432
+ finally:
1433
+ if tmp.exists():
1434
+ tmp.unlink()
1435
+
1436
+
1423
1437
  def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
1424
1438
  """Add `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` to the user's global settings, additively.
1425
1439
 
@@ -1470,9 +1484,7 @@ def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
1470
1484
  # whole function exists to notice.
1471
1485
  if raw:
1472
1486
  path.with_suffix(".json.caddis-bak").write_text(raw, encoding="utf-8")
1473
- tmp = path.with_suffix(".json.caddis-tmp")
1474
- tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1475
- os.replace(tmp, path)
1487
+ _atomic_write_json(path, data)
1476
1488
  verb = "would add" if dry else "added"
1477
1489
  return [f"user env: {verb} {TODO_TOOLS_ENV_KEY}=1 to {path}"
1478
1490
  + ("" if not raw else " (previous version saved as settings.json.caddis-bak)")]
@@ -1513,9 +1525,7 @@ def ensure_cleanup_period(dry: bool, home: Path | None = None, days: int = CLEAN
1513
1525
  path.parent.mkdir(parents=True, exist_ok=True)
1514
1526
  if raw:
1515
1527
  path.with_suffix(".json.caddis-retention-bak").write_text(raw, encoding="utf-8")
1516
- tmp = path.with_suffix(".json.caddis-tmp")
1517
- tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
1518
- os.replace(tmp, path)
1528
+ _atomic_write_json(path, data)
1519
1529
  verb = "would set" if dry else "set"
1520
1530
  return f"transcript retention: {verb} {CLEANUP_PERIOD_KEY}={days} in {path}"
1521
1531
 
@@ -58,6 +58,9 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" "<task description>"
58
58
  - **Worktree result:** PASS commits code changes to `lane/adhoc-<slug>` and removes the worktree. FAIL keeps it. A Claude-lane `PENDING` keeps it and prints the `NEXT:` step. Gates that never ran show `Gates: not run`.
59
59
  - **No-op and gate failures:** A run with no code changes fails and keeps its worktree. A pytest gate that collects no tests also fails.
60
60
  - **Verdict Card:** Prints PASS, FAIL, or PENDING with diff stats and next actions.
61
+ - **State per session:** The active task is saved in `.caddis/orchestrator/adhoc/latest-<session-id>.json` (session id from `CADDIS_SESSION_ID`, `CLAUDE_SESSION_ID` or `CLAUDE_CODE_SESSION_ID`). `keep`, `drop` and `status` use only your own session's task. An old un-keyed `latest.json` is adopted once by the first session that reads it.
62
+ - **Unique slug:** The `<slug>` ends in a random 6-character suffix, so two runs on the same day and topic never share a branch or plan.
63
+ - **No branch overwrite:** If `lane/adhoc-<slug>` already exists with commits not merged into the base (and is not this session's own recorded branch), the run fails, keeps its worktree and prints why.
61
64
 
62
65
  ### 2. Keep the Result
63
66
  ```bash
@@ -133,8 +133,9 @@ symmetric either: refusing early costs this session's remaining headroom, while
133
133
  handover costs the successor's whole session and everything built on the error.
134
134
 
135
135
  **Where the number comes from.** Claude Code hands `context_window.used_percentage` to the status
136
- line and to nothing else, so the caddis status line caches it per repo and `preflight` reads it
137
- back. **No status line on this machine?** Pass your own reading:
136
+ line and to nothing else, so the caddis status line caches it per repo and per session (`.caddis/context-window/<session id>.json`),
137
+ and `preflight` reads it back (your own session's file when `CLAUDE_SESSION_ID` is set, else the shared
138
+ `context-window.json`, which another session may have written last). **No status line on this machine?** Pass your own reading:
138
139
 
139
140
  ```bash
140
141
  python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" preflight --context-pct 72