aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/inbox-send.py
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/inbox-send.py')
-rwxr-xr-x.ai/scripts/inbox-send.py41
1 files changed, 38 insertions, 3 deletions
diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py
index 1ebb636..5787a84 100755
--- a/.ai/scripts/inbox-send.py
+++ b/.ai/scripts/inbox-send.py
@@ -31,6 +31,7 @@ import os
import re
import shutil
import sys
+import tempfile
from datetime import datetime
from pathlib import Path
@@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]:
config = Path.home() / ".claude" / "inbox-roots.txt"
if config.is_file():
paths: list[Path] = []
- for line in config.read_text().splitlines():
+ for line in config.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(line).expanduser())
@@ -194,6 +195,39 @@ def uniquify(dest: Path) -> Path:
n += 1
+def _atomic_write(dest: Path, writer) -> None:
+ """Write to a temp file in dest's directory, then rename it into place.
+
+ dest is another project's inbox/, and a direct write truncates the target
+ on open, so any mid-write failure (a full disk, an encoding error, an
+ interrupted process) leaves a zero-byte .org there. inbox-status counts
+ that phantom as a pending handoff and blocks a turn in the receiving
+ project over a file with no content and no sender (2026-07-23). Writing to
+ a temp sibling and os.replace-ing means the inbox only ever sees a complete
+ file. os.replace is atomic within one filesystem, and the temp sits in the
+ same directory as dest, so it is.
+
+ `writer` receives the open temp path and fills it. On any failure the temp
+ is removed and the error re-raised, so a caught error never leaves debris.
+ """
+ fd, tmp = tempfile.mkstemp(
+ dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix
+ )
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # mkstemp creates the temp 0600; give the delivered file the umask-default
+ # mode the old direct write produced, so inbox files stay readable as before.
+ umask = os.umask(0)
+ os.umask(umask)
+ os.chmod(tmp_path, 0o666 & ~umask)
+ try:
+ writer(tmp_path)
+ os.replace(tmp_path, dest)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def send_text(
target_inbox: Path,
message: str,
@@ -209,7 +243,8 @@ def send_text(
raise ValueError(f"could not derive a slug from text: {message!r}")
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org"
dest = uniquify(target_inbox / filename)
- dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT))
+ _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8"))
return dest
@@ -229,7 +264,7 @@ def send_file(
ext = src_path.suffix
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
dest = uniquify(target_inbox / filename)
- shutil.copy2(src_path, dest)
+ _atomic_write(dest, lambda p: shutil.copyfile(src_path, p))
return dest